Instancia transcoder t2: snapshot

This commit is contained in:
Joaquin
2026-07-13 15:20:15 +00:00
commit 257e94ef95
69 changed files with 28226 additions and 0 deletions
+10
View File
@@ -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
+49
View File
@@ -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.
+34
View File
@@ -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.
+56
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
node_modules/
data/*.db
.env
*.log
data/
user_data/
coverage/
.DS_Store
dist/
release/
.vscode
transcode-cache/
+56
View File
@@ -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"]
+675
View File
@@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under Article
11 of the WIPO Copyright Treaty adopted on 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+350
View File
@@ -0,0 +1,350 @@
<p align="center">
<img src="public/img/logo-banner.png" alt="nodecast-tv" height="60" />
</p>
# 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
<div align="center">
<img src="public/img/screenshots/screenshot-dashboard.png" width="45%" alt="Dashboard" />
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Live TV" />
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="TV Guide" />
<img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Movies" />
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Series" />
<img src="public/img/screenshots/screenshot-settings.png" width="45%" alt="Settings" />
</div>
## 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)<br>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.
+13
View File
@@ -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
+869
View File
@@ -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 @@
<meta name="mobile-web-app-capable" content="yes">
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -1095,20 +1095,20 @@
</div>
<!-- JavaScript -->
- <script src="/js/icons.js"></script>
- <script src="/js/api.js?v=2"></script>
- <script src="/js/components/VideoPlayer.js?v=2"></script>
- <script src="/js/components/ChannelList.js?v=3"></script>
- <script src="/js/components/SourceManager.js?v=2"></script>
- <script src="/js/components/EpgGuide.js?v=2"></script>
- <script src="/js/pages/HomePage.js?v=2"></script>
- <script src="/js/pages/LivePage.js?v=2"></script>
- <script src="/js/pages/Guide.js?v=2"></script>
- <script src="/js/pages/MoviesPage.js?v=4"></script>
- <script src="/js/pages/SeriesPage.js?v=1"></script>
- <script src="/js/pages/Settings.js?v=2"></script>
- <script src="/js/pages/WatchPage.js?v=1"></script>
- <script src="/js/app.js?v=4"></script>
+ <script src="js/icons.js"></script>
+ <script src="js/api.js?v=2"></script>
+ <script src="js/components/VideoPlayer.js?v=2"></script>
+ <script src="js/components/ChannelList.js?v=3"></script>
+ <script src="js/components/SourceManager.js?v=2"></script>
+ <script src="js/components/EpgGuide.js?v=2"></script>
+ <script src="js/pages/HomePage.js?v=2"></script>
+ <script src="js/pages/LivePage.js?v=2"></script>
+ <script src="js/pages/Guide.js?v=2"></script>
+ <script src="js/pages/MoviesPage.js?v=4"></script>
+ <script src="js/pages/SeriesPage.js?v=1"></script>
+ <script src="js/pages/Settings.js?v=2"></script>
+ <script src="js/pages/WatchPage.js?v=1"></script>
+ <script src="js/app.js?v=4"></script>
<script>
// Check for SSO token in URL
(function () {
@@ -1125,4 +1125,4 @@
</script>
</body>
-</html>
\ No newline at end of file
+</html>
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}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
@@ -623,7 +623,7 @@ class ChannelList {
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
@@ -1052,7 +1052,7 @@ class ChannelList {
div.innerHTML = `
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.getProgramInfo(channel) || ''}</div>
@@ -1305,7 +1305,7 @@ class ChannelList {
<div class="epg-info-modal">
<div class="channel-details">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" />
+ onerror="this.onerror=null;this.src='img/placeholder.png'" />
<div class="channel-meta">
<p><strong>Group:</strong> ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}</p>
<p><strong>Source:</strong> ${channel.sourceType}</p>
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}
</button>
<img class="epg-channel-logo" src="${logo}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<span class="epg-channel-name">${name}</span>
<div class="resize-handle"></div>
</div>
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 = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
@@ -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 `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
- <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
@@ -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 = '<div class="empty-state hint">No recently added movies found</div>';
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 = '<div class="empty-state hint">No recently added series found</div>';
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 `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
- <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
+ <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${percent}%"></div>
</div>
@@ -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 `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
- <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
+ <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
@@ -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 = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
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 = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
@@ -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 => `
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
- <img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
+ <img src="${movie.stream_icon || movie.cover || 'img/placeholder.png'}"
alt="${movie.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<p>${movie.name}</p>
</div>
`).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 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NodeCast TV</title>
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
@@ -228,7 +228,7 @@
<script>
// Start SSO Login
document.getElementById('btn-sso-login').addEventListener('click', () => {
- window.location.href = '/api/auth/oidc/login';
+ window.location.href = 'api/auth/oidc/login';
});
// Check for URL Error params
@@ -247,7 +247,7 @@
async function checkSetupRequired() {
try {
- const response = await fetch('/api/auth/setup-required');
+ const response = await fetch('api/auth/setup-required');
const data = await response.json();
if (data.setupRequired) {
@@ -283,7 +283,7 @@
submitBtn.textContent = 'Please wait...';
try {
- const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
+ const endpoint = isSetupMode ? 'api/auth/setup' : 'api/auth/login';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
@@ -302,7 +302,7 @@
localStorage.setItem('authToken', data.token);
// Redirect to main app (replace history to prevent back button issues)
- window.location.replace('/');
+ window.location.replace('index.html');
} catch (err) {
errorMessage.textContent = err.message;
@@ -317,4 +317,4 @@
</script>
</body>
-</html>
\ No newline at end of file
+</html>
+1869
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "kiratv",
"version": "2.1.4-kira1",
"description": "KiraTV — reproductor IPTV para el navegador (fork de NodeCast)",
"license": "GPL-3.0-only",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js"
},
"dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.2",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-openidconnect": "^0.1.2",
"sax": "^1.4.3",
"xml2js": "^0.6.2"
},
"optionalDependencies": {
"ffmpeg-static": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+4822
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64">
<stop offset="0%" stop-color="#7c3aed"/>
<stop offset="100%" stop-color="#4f46e5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<rect x="10" y="18" width="44" height="28" rx="4" stroke="#fff" stroke-width="3" fill="none"/>
<path d="M22 32 L28 38 L42 24" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+1128
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
/**
* API Client - Frontend API wrapper for NodeCast TV
*/
const API = {
/**
* Make API request
*/
async request(method, endpoint, data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json'
}
};
// Add authentication token if available
const token = localStorage.getItem('authToken');
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch(`/api${endpoint}`, options);
let result;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
result = await response.json();
} else {
const text = await response.text();
result = { error: text || 'API request failed' };
}
if (!response.ok) {
// If unauthorized, redirect to login
if (response.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
return;
}
throw new Error(result.error || `Server responded with ${response.status}`);
}
return result;
},
// 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)
},
// 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 }),
// 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 })
},
// Favorites
favorites: {
getAll: (sourceId = null, itemType = null) => {
let url = '/favorites';
const params = [];
if (sourceId) params.push(`sourceId=${sourceId}`);
if (itemType) params.push(`itemType=${itemType}`);
if (params.length) url += '?' + params.join('&');
return API.request('GET', url);
},
add: (sourceId, itemId, itemType = 'channel') =>
API.request('POST', '/favorites', { sourceId, itemId, itemType }),
remove: (sourceId, itemId, itemType = 'channel') =>
API.request('DELETE', '/favorites', { sourceId, itemId, itemType }),
check: (sourceId, itemId, itemType = 'channel') =>
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
},
// Proxy
proxy: {
// Xtream
xtream: {
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}`);
},
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}`);
},
vodCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
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}`);
},
seriesCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
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}`);
},
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}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
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 })
},
// Cache management
cache: {
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')
},
// 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}`)
}
};
// Make API available globally
window.API = API;
+293
View File
@@ -0,0 +1,293 @@
/**
* KiraTV Application Entry Point
*/
class App {
constructor() {
this.currentPage = 'home';
this.pages = {};
this.currentUser = null;
// Initialize components
this.player = new VideoPlayer();
this.channelList = new ChannelList();
this.sourceManager = new SourceManager();
this.epgGuide = new EpgGuide();
// Initialize page controllers
this.pages.home = new HomePage(this);
this.pages.live = new LivePage(this);
this.pages.guide = new GuidePage(this);
this.pages.movies = new MoviesPage(this);
this.pages.series = new SeriesPage(this);
this.pages.settings = new SettingsPage(this);
this.pages.watch = new WatchPage(this);
this.init();
}
async init() {
// Check authentication first
await this.checkAuth();
// Mobile menu toggle
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const navbarMenu = document.getElementById('navbar-menu');
if (mobileMenuToggle && navbarMenu) {
mobileMenuToggle.addEventListener('click', () => {
mobileMenuToggle.classList.toggle('active');
navbarMenu.classList.toggle('active');
});
// Close menu when a nav link is clicked
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', () => {
mobileMenuToggle.classList.remove('active');
navbarMenu.classList.remove('active');
});
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar')) {
mobileMenuToggle.classList.remove('active');
navbarMenu.classList.remove('active');
}
});
}
// Channel drawer toggle (mobile)
const channelToggleBtn = document.getElementById('channel-toggle-btn');
const channelSidebar = document.getElementById('channel-sidebar');
const channelOverlay = document.getElementById('channel-sidebar-overlay');
if (channelToggleBtn && channelSidebar && channelOverlay) {
const toggleChannelDrawer = () => {
channelSidebar.classList.toggle('active');
channelOverlay.classList.toggle('active');
};
channelToggleBtn.addEventListener('click', toggleChannelDrawer);
channelOverlay.addEventListener('click', toggleChannelDrawer);
// Close drawer when a channel is selected
channelSidebar.addEventListener('click', (e) => {
if (e.target.closest('.channel-item')) {
// Small delay to let the channel selection happen
setTimeout(() => {
channelSidebar.classList.remove('active');
channelOverlay.classList.remove('active');
}, 300);
}
});
}
// Desktop sidebar collapse toggle
const sidebarCollapseBtn = document.getElementById('sidebar-collapse-btn');
const sidebarExpandBtn = document.getElementById('sidebar-expand-btn');
const homeLayout = document.querySelector('.home-layout');
const toggleSidebarCollapse = () => {
channelSidebar?.classList.toggle('collapsed');
homeLayout?.classList.toggle('sidebar-collapsed');
// Persist preference
const isCollapsed = channelSidebar?.classList.contains('collapsed');
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
};
sidebarCollapseBtn?.addEventListener('click', toggleSidebarCollapse);
sidebarExpandBtn?.addEventListener('click', toggleSidebarCollapse);
// Restore sidebar state from localStorage
if (localStorage.getItem('sidebarCollapsed') === 'true') {
channelSidebar?.classList.add('collapsed');
homeLayout?.classList.add('sidebar-collapsed');
}
// Navigation handling
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo(link.dataset.page);
});
});
// Now Playing indicator
const nowPlayingBtn = document.getElementById('now-playing-indicator');
if (nowPlayingBtn) {
nowPlayingBtn.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo('watch');
});
}
// Toggle groups button
document.getElementById('toggle-groups').addEventListener('click', () => {
this.channelList.toggleAllGroups();
});
// Search clear buttons (global handler for all)
document.querySelectorAll('.search-clear').forEach(btn => {
btn.addEventListener('click', () => {
const wrapper = btn.closest('.search-wrapper');
const input = wrapper?.querySelector('.search-input');
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
});
});
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
const page = e.state?.page || 'home';
this.navigateTo(page, false); // false = don't add to history
});
// Initialize home page first (it's needed for channel list)
await this.pages.home.init();
// Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => {
console.warn('Background EPG load failed:', err.message);
});
// Navigate to the page from URL hash, or default to home
const hash = window.location.hash.slice(1); // Remove #
const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('KiraTV initialized');
}
async checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) {
// No token, redirect to login (replace to avoid back button issues)
window.location.replace('/login.html');
return;
}
try {
// Verify token with server
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Invalid token');
}
this.currentUser = await response.json();
// Hide settings for viewers
if (this.currentUser.role === 'viewer') {
const settingsLink = document.querySelector('.nav-link[data-page="settings"]');
if (settingsLink) {
settingsLink.style.display = 'none';
}
}
// Add logout button to navbar
this.addLogoutButton();
} catch (err) {
console.error('Authentication error:', err);
localStorage.removeItem('authToken');
window.location.replace('/login.html');
}
}
addLogoutButton() {
const navbar = document.querySelector('.navbar-menu');
if (!navbar || document.getElementById('logout-btn')) return;
const logoutLink = document.createElement('a');
logoutLink.href = '#';
logoutLink.className = 'nav-link';
logoutLink.id = 'logout-btn';
logoutLink.innerHTML = `
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg></span>
<span>Logout</span>
`;
logoutLink.addEventListener('click', async (e) => {
e.preventDefault();
const token = localStorage.getItem('authToken');
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
}
localStorage.removeItem('authToken');
window.location.replace('/login.html');
});
navbar.appendChild(logoutLink);
}
navigateTo(pageName, replaceHistory = false) {
// Don't navigate if already on this page
if (this.currentPage === pageName && !replaceHistory) {
return;
}
// Update browser history
if (replaceHistory) {
// Replace current history entry (used on initial load)
history.replaceState({ page: pageName }, '', `#${pageName}`);
} else {
// Add new history entry
history.pushState({ page: pageName }, '', `#${pageName}`);
}
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === pageName);
});
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageName}`);
});
// Notify page controllers
if (this.pages[this.currentPage]?.hide) {
this.pages[this.currentPage].hide();
}
this.currentPage = pageName;
if (this.pages[pageName]?.show) {
this.pages[pageName].show();
}
}
}
// Start app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.app = new App();
// Fetch and display version badge
fetch('/api/version')
.then(res => res.json())
.then(data => {
const badge = document.getElementById('version-badge');
if (badge && data.version) badge.textContent = `v${data.version}`;
})
.catch(() => { });
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Auth Manager - Frontend authentication state management
*/
const Auth = {
currentUser: null,
/**
* Initialize auth - check setup status and current user
*/
async init() {
try {
// Check if setup is required
const setupStatus = await API.auth.checkSetup();
if (setupStatus.setupRequired) {
this.showSetup();
return false;
}
// Check if user is logged in
if (API.getToken()) {
try {
this.currentUser = await API.auth.me();
return true;
} catch (error) {
// Token invalid, clear it
console.log('Token invalid, showing login');
API.setToken(null);
this.showLogin();
return false;
}
} else {
this.showLogin();
return false;
}
} catch (error) {
console.error('Auth initialization failed:', error);
// On any error, show login (don't loop)
this.showLogin();
return false;
}
},
/**
* Show setup screen
*/
showSetup() {
document.getElementById('setup-screen').classList.add('active');
document.getElementById('login-screen').classList.remove('active');
document.getElementById('app').classList.remove('active');
},
/**
* Show login screen
*/
showLogin() {
document.getElementById('setup-screen').classList.remove('active');
document.getElementById('login-screen').classList.add('active');
document.getElementById('app').classList.remove('active');
},
/**
* Show main app
*/
showApp() {
document.getElementById('setup-screen').classList.remove('active');
document.getElementById('login-screen').classList.remove('active');
document.getElementById('app').classList.add('active');
// Hide settings tab if viewer
if (!this.isAdmin()) {
const settingsLink = document.querySelector('[data-page="settings"]');
if (settingsLink) {
settingsLink.parentElement.style.display = 'none';
}
}
},
/**
* Setup initial admin user
*/
async setup(username, password) {
try {
const result = await API.auth.setup(username, password);
API.setToken(result.token);
this.currentUser = result.user;
this.showApp();
return true;
} catch (error) {
throw error;
}
},
/**
* Login user
*/
async login(username, password) {
try {
const result = await API.auth.login(username, password);
API.setToken(result.token);
this.currentUser = result.user;
this.showApp();
return true;
} catch (error) {
throw error;
}
},
/**
* Logout user
*/
async logout() {
try {
await API.auth.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
API.setToken(null);
this.currentUser = null;
this.showLogin();
}
},
/**
* Check if current user is admin
*/
isAdmin() {
return this.currentUser && this.currentUser.role === 'admin';
},
/**
* Check if current user is viewer
*/
isViewer() {
return this.currentUser && this.currentUser.role === 'viewer';
},
/**
* Get current user
*/
getCurrentUser() {
return this.currentUser;
}
};
File diff suppressed because it is too large Load Diff
+931
View File
@@ -0,0 +1,931 @@
/**
* EPG Guide Component
* Cable TV-style program guide grid
*/
class EpgGuide {
constructor() {
this.container = document.getElementById('epg-grid');
this.dateDisplay = document.getElementById('guide-date');
this.prevBtn = document.getElementById('guide-prev');
this.nextBtn = document.getElementById('guide-next');
this.nextBtn = document.getElementById('guide-next');
this.groupSelect = document.getElementById('epg-group-select');
this.searchInput = document.getElementById('epg-search');
this.channels = [];
this.programmes = [];
this.currentDate = new Date();
this.timeOffset = 0; // Hours offset from now
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
this.favorites = new Set(); // Set<"sourceId:channelId">
this.selectedGroup = 'Favorites'; // Default to Favorites
// Virtual scrolling properties
this.filteredChannels = [];
this.rowHeight = 60; // Height of each channel row in pixels
this.bufferRows = 5; // Extra rows to render above/below viewport
this.startTime = null;
this.endTime = null;
this.epgContainer = null;
this.epgSpacer = null;
this.scrollContainer = null;
this.visibleRows = new Map(); // Map<index, rowElement>
this._scrollHandler = null;
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
this.init();
}
/**
* Get proxied image URL to avoid mixed content errors on HTTPS
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
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 url;
}
init() {
this.prevBtn.addEventListener('click', () => this.navigate(-2));
this.nextBtn.addEventListener('click', () => this.navigate(2));
// Group filter change
this.groupSelect?.addEventListener('change', () => {
this.selectedGroup = this.groupSelect.value;
this.render();
});
// Search input
if (this.searchInput) {
this.searchInput.addEventListener('input', this.debounce(() => {
this.render();
}, 300));
}
// Update current time indicator every minute
setInterval(() => this.updateNowIndicator(), 60000);
this.initResizer();
}
/**
* Initialize sidebar resizer
*/
initResizer() {
let isResizing = false;
let startX, startWidth;
document.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('resize-handle')) {
isResizing = true;
startX = e.clientX;
const sidebar = document.querySelector('.epg-channel-info');
startWidth = sidebar.getBoundingClientRect().width;
e.target.classList.add('active');
document.body.style.cursor = 'col-resize';
e.preventDefault(); // Prevent text selection
}
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const diff = e.clientX - startX;
const newWidth = Math.max(150, Math.min(500, startWidth + diff)); // Min 150px, Max 500px
document.documentElement.style.setProperty('--epg-sidebar-width', `${newWidth}px`);
this.updateNowIndicator(); // Update line position
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.querySelectorAll('.resize-handle').forEach(h => h.classList.remove('active'));
document.body.style.cursor = '';
}
});
}
/**
* Navigate time
*/
navigate(hours) {
this.timeOffset += hours;
this.render();
}
/**
* Start background EPG display refresh timer
* This only refreshes the UI from cached server data.
* The actual sync runs on the server independently.
*/
startBackgroundRefresh() {
// Clear any existing timer
this.stopBackgroundRefresh();
// Refresh display from cache every 5 minutes to pick up server-side sync results
const refreshIntervalMs = 5 * 60 * 1000; // 5 minutes
console.log('[EPG] Starting display refresh timer: every 5 minutes');
this._backgroundRefreshTimer = setInterval(async () => {
console.log('[EPG] Refreshing EPG display from cache');
try {
await this.fetchEpgData(false); // Fetch cached data (no force refresh)
// Update channel list program info if visible
if (window.app?.channelList) {
window.app.channelList.clearProgramInfoCache();
window.app.channelList.updateVisibleEpgInfo?.();
}
} catch (err) {
console.error('[EPG] Display refresh failed:', err);
}
}, refreshIntervalMs);
}
/**
* Stop background EPG refresh timer
*/
stopBackgroundRefresh() {
if (this._backgroundRefreshTimer) {
clearInterval(this._backgroundRefreshTimer);
this._backgroundRefreshTimer = null;
console.log('[EPG] Background refresh timer stopped');
}
}
/**
* Get last refresh time for display
*/
getLastRefreshTime() {
return this.lastRefreshTime || null;
}
/**
* Load EPG data (server-side caching)
*/
async loadEpg(forceRefresh = false) {
try {
this.container.innerHTML = '<div class="loading"></div>';
await this.fetchEpgData(forceRefresh);
this.lastRefreshTime = new Date();
this.render();
// Start background refresh timer after initial load
// This ensures EPG data stays fresh while the app is open
this.startBackgroundRefresh();
} catch (err) {
console.error('Error loading EPG:', err);
this.container.innerHTML = `
<div class="empty-state">
<p>Error loading EPG</p>
<p class="hint">${err.message}</p>
</div>
`;
}
}
/**
* Fetch EPG data from sources
*/
async fetchEpgData(forceRefresh = false) {
// Get ALL sources and filter for EPG-capable types
const allSources = await API.sources.getAll();
const sources = allSources.filter(s => (s.type === 'epg' || s.type === 'xtream') && s.enabled);
if (sources.length === 0) {
throw new Error('No EPG sources or Xtream accounts configured');
}
// Build query params for server-side caching
// Sync interval is controlled by server, we just hint at max cache age
const maxAge = 24; // hours - server controls actual refresh
const queryParams = forceRefresh ? '?refresh=1' : `?maxAge=${maxAge}`;
// Load EPG from ALL sources in parallel
const fetchPromises = sources.map(async (source) => {
try {
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) {
console.warn(`Failed to load EPG for source ${source.name}:`, e);
return null;
}
});
const results = await Promise.all(fetchPromises);
// Merge results
this.channels = [];
this.programmes = [];
let hasData = false;
results.forEach(data => {
if (data) {
if (data.channels && data.channels.length > 0) {
this.channels = this.channels.concat(data.channels);
}
if (data.programmes && data.programmes.length > 0) {
this.programmes = this.programmes.concat(data.programmes);
}
if (data.channels || data.programmes) {
hasData = true;
}
}
});
if (!hasData) {
throw new Error('Failed to load EPG data from any source');
}
// Build secondary indexes for faster lookup
this.channelMap = new Map();
// Index by ID
this.channels.forEach(ch => {
this.channelMap.set(ch.id, ch);
// Also index by name (normalized) for fallback matching
if (ch.name) {
this.channelMap.set(ch.name.toLowerCase(), ch);
}
});
// Load favorites
const favs = await API.favorites.getAll();
this.favorites = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
}
/**
* Get current program for a channel
* @param {string} tvgId - The EPG channel ID (tvg-id)
* @param {string} channelName - The channel name (for fallback)
* @returns {object|null} Program object with title, start, stop
*/
getCurrentProgram(tvgId, channelName) {
if (!this.programmes || this.programmes.length === 0) return null;
// Find EPG channel using fast map lookup
let epgChannel = null;
if (tvgId && this.channelMap && this.channelMap.has(tvgId)) {
epgChannel = this.channelMap.get(tvgId);
} else if (channelName && this.channelMap) {
epgChannel = this.channelMap.get(channelName.toLowerCase());
} else {
// Fallback to slow search if map fails or not built yet
epgChannel = this.channels.find(epg =>
(tvgId && epg.id === tvgId) || epg.name === channelName
);
}
if (!epgChannel) return null;
const now = new Date();
const nowTime = now.getTime();
// Filter programs for this channel
const current = this.programmes.find(p => {
if (p.channelId !== epgChannel.id) return false;
const start = new Date(p.start).getTime();
const stop = new Date(p.stop).getTime();
return nowTime >= start && nowTime < stop;
});
return current ? {
title: current.title,
start: current.start,
stop: current.stop,
description: current.description
} : null;
}
/**
* Update filtered channels based on search or group
*/
updateFilteredChannels() {
const searchTerm = this.searchInput ? this.searchInput.value.toLowerCase().trim() : '';
// SEARCH MODE: Filter all channels by name
if (searchTerm) {
this.filteredChannels = this.allMatchedChannels.filter(ch => {
const name = (ch.sourceChannel?.name || '').toLowerCase();
return name.includes(searchTerm);
});
return;
}
// GROUP MODE (Default)
if (this.selectedGroup === 'Favorites') {
this.filteredChannels = this.allMatchedChannels.filter(m =>
this.favorites.has(`${m.sourceChannel.sourceId}:${m.sourceChannel.id}`)
);
return;
}
if (!this.selectedGroup || this.selectedGroup === 'All') {
this.filteredChannels = [...this.allMatchedChannels];
} else {
this.filteredChannels = this.allMatchedChannels.filter(m =>
(m.sourceChannel.groupTitle || 'Uncategorized') === this.selectedGroup
);
}
}
/**
* Render the EPG grid
*/
render() {
// Get channel list instance
const channelList = window.app?.channelList;
if (!channelList) return;
// Get channels and filter out hidden ones (always enforce hidden in EPG)
// Note: We only check individual channel visibility, not group visibility
// A group is implicitly visible if it has any visible children
const playableChannels = (channelList.channels || []).filter(ch => {
// Use streamId (raw ID) for hidden check since that's what SourceManager stores
const rawChannelId = ch.streamId || ch.id;
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
return !isChannelHidden;
});
if (playableChannels.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>No visible channels available</p>
<p class="hint">Check your content settings or add a source</p>
</div>
`;
return;
}
// Match ALL playable channels with optional EPG data
const allChannels = playableChannels.map(sourceChannel => {
// Try to find matching EPG channel by tvgId or name
const epgChannel = this.channels.find(epg =>
epg.id === sourceChannel.tvgId || epg.name === sourceChannel.name
);
return { epgChannel, sourceChannel };
});
// Collect unique groups from ALL playable channels
const groups = [...new Set(allChannels.map(m => m.sourceChannel.groupTitle || 'Uncategorized'))].sort();
// Add Favorites at the top if there are any
const hasFavorites = this.favorites.size > 0;
// Only rebuild dropdown if groups have changed (performance optimization)
const groupsKey = groups.join('|') + (hasFavorites ? '|FAV' : '');
if (this.groupSelect && this._lastGroupsKey !== groupsKey) {
this._lastGroupsKey = groupsKey;
const currentValue = this.selectedGroup;
let optionsHtml = '';
if (hasFavorites) {
optionsHtml += `<option value="Favorites" ${currentValue === 'Favorites' ? 'selected' : ''}>Favorites</option>`;
}
optionsHtml += `<option value="" ${currentValue === '' ? 'selected' : ''}>All Groups</option>`;
optionsHtml += groups.map(g => `<option value="${g}" ${g === currentValue ? 'selected' : ''}>${g}</option>`).join('');
this.groupSelect.innerHTML = optionsHtml;
} else if (this.groupSelect) {
// Just update the selected value without rebuilding
this.groupSelect.value = this.selectedGroup;
}
// Handle case where we defaulted to Favorites but user has none
if (this.selectedGroup === 'Favorites' && !hasFavorites) {
this.selectedGroup = ''; // Fallback to 'All Groups'
if (this.groupSelect) this.groupSelect.value = '';
}
// Store all channels (matched with EPG data) for filtering
this.allMatchedChannels = allChannels;
this.updateFilteredChannels();
// Calculate time range and store for batch rendering
this.startTime = new Date();
this.startTime.setHours(this.startTime.getHours() + this.timeOffset);
this.startTime.setMinutes(0, 0, 0);
this.endTime = new Date(this.startTime);
this.endTime.setHours(this.endTime.getHours() + 24); // Show 24 hours of programming
// Update date display
this.updateDateDisplay(this.startTime);
// Generate time slots
const timeSlots = this.generateTimeSlots(this.startTime, this.endTime);
// Calculate total height for virtual scrolling
const totalHeight = this.filteredChannels.length * this.rowHeight;
// Build HTML structure - header is INSIDE scroll container for natural sync
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-scroll-container" style="overflow: auto; max-height: calc(100vh - 200px);">
<div class="epg-time-header">
<div class="epg-header-corner"></div>
<div class="epg-time-slots">
${timeSlots.map(slot => `
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
`).join('')}
</div>
</div>
<div class="epg-spacer" style="height: ${totalHeight}px; position: relative;">
<div class="epg-channel-rows" style="position: absolute; top: 0; left: 0; right: 0;"></div>
</div>
</div>
</div>
`;
// Get references for virtual scrolling
this.scrollContainer = this.container.querySelector('.epg-scroll-container');
this.epgSpacer = this.container.querySelector('.epg-spacer');
this.epgContainer = this.container.querySelector('.epg-channel-rows');
// Clear visible rows cache
this.visibleRows.clear();
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
// Remove old scroll handler if exists
if (this._scrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._scrollHandler);
}
if (this._hScrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._hScrollHandler);
}
// Get reference to time slots container for horizontal scroll sync
this.timeSlotsContainer = this.container.querySelector('.epg-time-slots');
// Set up scroll handler for virtual scrolling (debounced for performance)
this._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
// No need for horizontal scroll sync anymore - header is inside scroll container
// Initial render of visible rows
this.updateVisibleRows();
// Sync header corner width with actual sidebar width (handles CSS overrides)
this.syncHeaderCornerWidth();
// Add now indicator and set up periodic refresh
this.updateNowIndicator();
// Clear any existing interval
if (this._nowIndicatorInterval) {
clearInterval(this._nowIndicatorInterval);
}
// Update indicator every 60 seconds
this._nowIndicatorInterval = setInterval(() => this.updateNowIndicator(), 60000);
}
/**
* Sync header corner width with actual sidebar width
* This handles cases where CSS variables are overridden on mobile
*/
syncHeaderCornerWidth() {
const sidebar = this.container.querySelector('.epg-channel-info');
const headerCorner = this.container.querySelector('.epg-header-corner');
if (sidebar && headerCorner) {
const actualWidth = sidebar.offsetWidth;
headerCorner.style.width = `${actualWidth}px`;
}
}
/**
* Update visible rows based on scroll position (Virtual Scrolling)
*/
updateVisibleRows() {
if (!this.scrollContainer || !this.epgContainer) return;
const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight;
// Calculate visible range
const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.bufferRows);
const endIndex = Math.min(
this.filteredChannels.length - 1,
Math.ceil((scrollTop + viewportHeight) / this.rowHeight) + this.bufferRows
);
// Skip if nothing changed
if (startIndex === this._lastVisibleStart && endIndex === this._lastVisibleEnd) {
return;
}
this._lastVisibleStart = startIndex;
this._lastVisibleEnd = endIndex;
// Determine which rows to add and remove
const newVisibleSet = new Set();
for (let i = startIndex; i <= endIndex; i++) {
newVisibleSet.add(i);
}
// Remove rows that are no longer visible
for (const [index, row] of this.visibleRows) {
if (!newVisibleSet.has(index)) {
row.remove();
this.visibleRows.delete(index);
}
}
// Add new visible rows
for (let i = startIndex; i <= endIndex; i++) {
if (!this.visibleRows.has(i) && i < this.filteredChannels.length) {
const row = this.createChannelRow(i);
this.visibleRows.set(i, row);
this.epgContainer.appendChild(row);
}
}
}
/**
* Create a channel row element for virtual scrolling
*/
createChannelRow(index) {
const { epgChannel, sourceChannel } = this.filteredChannels[index];
const isFavorite = this.favorites.has(`${sourceChannel.sourceId}:${sourceChannel.id}`);
// Get programs if EPG data exists
let channelProgrammes = [];
if (epgChannel) {
channelProgrammes = this.programmes
.filter(p => p.channelId === epgChannel.id)
.filter(p => {
const start = new Date(p.start);
const stop = new Date(p.stop);
return start < this.endTime && stop > this.startTime;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
}
// Fallback values if EPG channel is missing
const logo = this.getProxiedImageUrl(sourceChannel.tvgLogo || (epgChannel && epgChannel.icon));
const name = sourceChannel.name || (epgChannel && epgChannel.name);
const row = document.createElement('div');
row.className = 'epg-channel-row';
row.dataset.channelId = sourceChannel.id;
row.dataset.sourceId = sourceChannel.sourceId;
row.dataset.index = index;
// Position absolutely for virtual scrolling
row.style.position = 'absolute';
row.style.top = `${index * this.rowHeight}px`;
row.style.left = '0';
row.style.right = '0';
row.style.height = `${this.rowHeight}px`;
row.innerHTML = `
<div class="epg-channel-info">
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
</button>
<img class="epg-channel-logo" src="${logo}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<span class="epg-channel-name">${name}</span>
<div class="resize-handle"></div>
</div>
<div class="epg-programs">
${this.renderProgrammes(channelProgrammes, this.startTime, this.endTime)}
</div>
`;
this.attachRowListeners(row);
return row;
}
/**
* Attach event listeners to an EPG row
*/
attachRowListeners(row) {
// Program click handlers
row.querySelectorAll('.epg-program').forEach(prog => {
prog.addEventListener('click', () => this.showProgramDetails(prog.dataset));
});
const info = row.querySelector('.epg-channel-info');
if (info) {
// Name/Logo click plays channel
info.querySelector('.epg-channel-name')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
info.querySelector('.epg-channel-logo')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
// Favorite click
const favBtn = info.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const sourceId = parseInt(row.dataset.sourceId);
const channelId = row.dataset.channelId;
await this.toggleFavorite(sourceId, channelId);
});
}
}
}
/**
* Toggle favorite
*/
async toggleFavorite(sourceId, channelId) {
const key = `${sourceId}:${channelId}`;
const wasFavorite = this.favorites.has(key);
const isNowFavorite = !wasFavorite;
// 1. Optimistic Update (EPG)
if (isNowFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
// Update DOM (All matching buttons in EPG)
const rows = this.container.querySelectorAll(`.epg-channel-row[data-channel-id="${channelId}"][data-source-id="${sourceId}"]`);
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (isNowFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// 2. Sync Channel List State (Optimistic)
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, isNowFavorite);
}
try {
// 3. API Call
if (wasFavorite) {
await API.favorites.remove(sourceId, channelId, 'channel');
} else {
await API.favorites.add(sourceId, channelId, 'channel');
}
// 4. Re-render if viewing Favorites group (so new favorites appear immediately)
if (this.selectedGroup === 'Favorites') {
this.render();
}
} catch (err) {
console.error('Error toggling favorite in EPG:', err);
// Revert EPG
if (wasFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (wasFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// Revert Channel List
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, wasFavorite);
}
}
}
/**
* Sync favorite status from external source (e.g. ChannelList) without API call
*/
syncFavorite(sourceId, channelId, isFavorite) {
// Ensure consistent string format for key
const key = `${String(sourceId)}:${String(channelId)}`;
const currentlyFav = this.favorites.has(key);
if (currentlyFav === isFavorite) return; // No change needed
// Update State
if (isFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
// Update DOM (All instances in EPG)
const rows = this.container.querySelectorAll(`.epg-channel-row[data-channel-id="${channelId}"][data-source-id="${sourceId}"]`);
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (isFavorite) {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
}
}
});
// Note: We don't call render() here - the favorites Set is updated
// and will be used when the user navigates to Guide or switches groups
}
/**
* Generate time slots
*/
generateTimeSlots(start, end) {
const slots = [];
const current = new Date(start);
while (current < end) {
slots.push(new Date(current));
current.setMinutes(current.getMinutes() + 30);
}
return slots;
}
/**
* Render programmes for a channel
*/
renderProgrammes(programmes, startTime, endTime) {
if (programmes.length === 0) {
const width = (endTime - startTime) / 60000 * this.pixelsPerMinute;
return `<div class="epg-program" style="width: ${width}px;"><span class="epg-program-title">No data</span></div>`;
}
const now = new Date();
let html = '';
let currentPos = startTime.getTime();
for (const prog of programmes) {
const progStart = Math.max(new Date(prog.start).getTime(), startTime.getTime());
const progEnd = Math.min(new Date(prog.stop).getTime(), endTime.getTime());
// Fill gap if needed
if (progStart > currentPos) {
const gapWidth = (progStart - currentPos) / 60000 * this.pixelsPerMinute;
html += `<div class="epg-program" style="width: ${gapWidth}px;"></div>`;
}
const width = (progEnd - progStart) / 60000 * this.pixelsPerMinute;
const isCurrent = new Date(prog.start) <= now && new Date(prog.stop) > now;
html += `
<div class="epg-program ${isCurrent ? 'current' : ''}"
style="width: ${width}px;"
data-title="${prog.title || ''}"
data-description="${prog.description || ''}"
data-start="${prog.start}"
data-stop="${prog.stop}">
<div class="epg-program-title">${prog.title || 'Unknown'}</div>
<div class="epg-program-time">
${new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
`;
currentPos = progEnd;
}
return html;
}
/**
* Update date display
*/
updateDateDisplay(date) {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
this.dateDisplay.textContent = 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
this.dateDisplay.textContent = 'Tomorrow';
} else {
this.dateDisplay.textContent = date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
}
/**
* Debounce utility
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
updateNowIndicator() {
const now = new Date();
// Place indicator inside the spacer so it scrolls with content
const spacer = this.container.querySelector('.epg-spacer');
if (!spacer) return;
// Remove existing indicator
const existing = spacer.querySelector('.epg-now-line');
if (existing) existing.remove();
// Calculate position relative to EPG start time
const minutesFromStart = (now - this.startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 1440) return; // Not in visible 24h range
// Get sidebar width to offset the indicator
const sidebar = this.container.querySelector('.epg-channel-info');
const sidebarWidth = sidebar ? sidebar.offsetWidth : 150;
// Position is sidebar + time offset
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
const indicator = document.createElement('div');
indicator.className = 'epg-now-line';
indicator.style.left = `${leftPos}px`;
spacer.appendChild(indicator);
}
/**
* Show program details modal
*/
showProgramDetails(data) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
title.textContent = data.title || 'Program Details';
const start = new Date(data.start);
const stop = new Date(data.stop);
body.innerHTML = `
<p><strong>Time:</strong> ${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${stop.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
<p><strong>Description:</strong></p>
<p>${data.description || 'No description available'}</p>
`;
footer.innerHTML = '<button class="btn btn-secondary" id="modal-close">Close</button>';
modal.classList.add('active');
document.getElementById('modal-close').onclick = () => modal.classList.remove('active');
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
}
/**
* Play channel from EPG
*/
async playChannel(channelName) {
// Find channel in channel list and play
if (window.app?.channelList) {
const channel = window.app.channelList.channels.find(c =>
c.name === channelName || c.tvgName === channelName
);
if (channel) {
await window.app.channelList.selectChannel({ channelId: channel.id });
// Switch to live TV page
document.querySelector('[data-page="live"]').click();
}
}
}
}
// Export
window.EpgGuide = EpgGuide;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
/**
* Application Icons (SVG)
* Replaces emojis for consistent cross-platform appearance
*/
const Icons = {
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>`, // TV with Play
live: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>`, // TV with Play (Same as Logo)
guide: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>`, // List
movies: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z"/></svg>`, // Film
series: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>`, // TV Generic
settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`,
favorite: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
favoriteOutline: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"/></svg>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M8 5v14l11-7z"/></svg>`,
hide: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>`,
save: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>`,
star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon text-warning"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>`,
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>`,
expandAll: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></svg>`, // Unfold More
collapseAll: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M7.41 18.59L8.83 20 12 16.83 15.17 20l1.41-1.41L12 14l-4.59 4.59zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10l4.59-4.59z"/></svg>`, // Unfold Less
refresh: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
circle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/></svg>`,
};
window.Icons = Icons;
+14
View File
@@ -0,0 +1,14 @@
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await API.request('POST', '/auth/login', { username, password });
localStorage.setItem('sessionToken', response.token);
window.location.href = '/';
} catch (err) {
document.getElementById('login-error').textContent = 'Login failed: ' + err.message;
}
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Guide Page Controller
*/
class GuidePage {
constructor(app) {
this.app = app;
}
async init() {
// EPG guide will lazy load when shown
}
async show() {
// Ensure channel data is loaded before rendering EPG
// This fixes a race condition where navigating directly to the Guide page
// before visiting Live TV would result in an empty EPG.
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Only load EPG data if not already loaded
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg();
} else {
// Just re-render with existing data (updates time position)
this.app.epgGuide.render();
}
}
hide() {
// Page is hidden
}
}
window.GuidePage = GuidePage;
+512
View File
@@ -0,0 +1,512 @@
/**
* Home Dashboard Page
* Features "Continue Watching" and "Recently Added" content
*/
class HomePage {
constructor(app) {
this.app = app;
this.container = null; // Will be set in renderLayout
this.isLoading = false;
}
async init() {
// Initialization if needed
}
async show() {
this.renderLayout();
await this.loadDashboardData();
}
hide() {
// Cleanup if needed
if (this.container) {
this.container.innerHTML = '';
}
}
renderLayout() {
const pageHome = document.getElementById('page-home');
if (!pageHome) return;
pageHome.innerHTML = `
<div class="dashboard-content" id="home-content">
<section class="dashboard-section" id="favorite-channels-section">
<div class="section-header">
<h2>Favorite Channels</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll channel-tiles" id="favorite-channels-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading favorites...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section" id="continue-watching-section">
<div class="section-header">
<h2>Continue Watching</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="continue-watching-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading history...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Movies</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-movies-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Series</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-series-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
</div>
`;
this.container = document.getElementById('home-content');
// Attach scroll arrow handlers
this.initScrollArrows();
}
initScrollArrows() {
this.container.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
const scrollContainer = wrapper.querySelector('.horizontal-scroll');
const leftBtn = wrapper.querySelector('.scroll-left');
const rightBtn = wrapper.querySelector('.scroll-right');
if (!scrollContainer || !leftBtn || !rightBtn) return;
const scrollAmount = 300; // pixels to scroll per click
leftBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
rightBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Update arrow visibility based on scroll position
const updateArrows = () => {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
leftBtn.classList.toggle('hidden', scrollLeft <= 0);
rightBtn.classList.toggle('hidden', scrollLeft + clientWidth >= scrollWidth - 5);
};
// Store reference for later updates
wrapper._updateArrows = updateArrows;
scrollContainer.addEventListener('scroll', updateArrows);
// Initial check after content loads
setTimeout(updateArrows, 100);
});
}
/**
* Re-check scroll arrow visibility for all sections
* Call this after dynamically loading content
*/
updateScrollArrows() {
this.container?.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
if (wrapper._updateArrows) {
wrapper._updateArrows();
}
});
}
async loadDashboardData() {
if (this.isLoading) return;
this.isLoading = true;
try {
// 0. Load Favorite Channels (first section)
await this.renderFavoriteChannels();
// 1. Load Watch History
const history = await window.API.request('GET', '/history?limit=12');
if (history && Array.isArray(history)) {
this.renderHistory(history);
}
// 2. Load Recent Items
this.renderRecentMovies();
this.renderRecentSeries();
} catch (err) {
console.error('[Dashboard] Error loading data:', err);
} finally {
this.isLoading = false;
}
}
async renderFavoriteChannels() {
const list = document.getElementById('favorite-channels-list');
const section = document.getElementById('favorite-channels-section');
if (!list || !section) return;
try {
// Fetch favorite channels for current user
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Ensure channel list is loaded to resolve channel details
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Match favorites to channel data
const channels = [];
for (const fav of favorites) {
// Find channel in loaded channel list
const channel = channelList.channels.find(ch =>
String(ch.sourceId) === String(fav.source_id) &&
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
);
if (channel) {
channels.push({ ...channel, favoriteId: fav.id });
}
}
if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Render channel tiles
list.innerHTML = channels.map(ch => this.createChannelTile(ch)).join('');
// Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId;
const sourceId = tile.dataset.sourceId;
this.playChannel(channelId, sourceId);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading favorite channels:', err);
list.innerHTML = '<div class="empty-state hint">Error loading favorites</div>';
}
}
createChannelTile(channel) {
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 `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
`;
}
playChannel(channelId, sourceId) {
// Navigate to Live TV and select the channel
this.app.navigateTo('live');
// Small delay to ensure page is ready
setTimeout(() => {
const channelList = this.app.channelList;
if (channelList) {
// Find and select the channel
const channel = channelList.channels.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
);
if (channel) {
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
}
renderHistory(items) {
const list = document.getElementById('continue-watching-list');
const section = document.getElementById('continue-watching-section');
if (!list || !section) return;
if (items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
list.innerHTML = items.map(item => this.createCard(item)).join('');
// Attach click listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = items.find(i => i.item_id === id);
if (item) {
const type = item.item_type || item.type;
// IF it's a series, checking details is better than blind resume
// BUT for "Continue Watching", we ideally want to resume
// Prioritize playing directly for resume tiles
this.playItem(item, true); // true for resume
}
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
}
navigateToSeries(item) {
if (!this.app.pages.series) return;
// Prepare the series object as expected by SeriesPage.showSeriesDetails
const series = {
series_id: item.item_id,
sourceId: item.source_id,
name: item.name || (item.data ? item.data.title : 'Series'),
cover: item.stream_icon || (item.data ? item.data.poster : null),
plot: item.data ? item.data.description : '',
year: item.data ? item.data.year : ''
};
// Switch page
this.app.navigateTo('series');
// Show details (delay slightly to ensure page is visible)
setTimeout(() => {
this.app.pages.series.showSeriesDetails(series);
}, 100);
}
async renderRecentMovies() {
const list = document.getElementById('recent-movies-list');
if (!list) return;
try {
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
return;
}
list.innerHTML = movies.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = movies.find(m => m.item_id === id);
if (item) this.playItem(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent movies:', err);
}
}
async renderRecentSeries() {
const list = document.getElementById('recent-series-list');
if (!list) return;
try {
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
if (!series || series.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
return;
}
list.innerHTML = series.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = series.find(s => s.item_id === id);
if (item) this.navigateToSeries(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent series:', err);
}
}
createCard(item) {
const { data, progress, duration, item_id } = item;
const type = item.item_type || item.type;
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;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${percent}%"></div>
</div>
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || data.title}">${item.name || data.title || 'Unknown Title'}</div>
<div class="card-subtitle">${data.subtitle || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
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;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || (data && data.title)}">${item.name || (data && data.title) || 'Unknown Title'}</div>
<div class="card-subtitle">${(data && data.subtitle) || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
async playItem(item, isResume = false) {
if (!this.app.pages.watch) return;
try {
const type = item.item_type || item.type;
const streamType = type === 'movie' ? 'movie' : 'series';
const sourceId = item.source_id || (item.data && item.data.sourceId);
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}`);
if (result && result.url) {
const content = {
id: item.item_id,
type: type,
title: item.name || item.data.title,
subtitle: item.data.subtitle || (type === 'movie' ? 'Movie' : 'Series'),
poster: item.stream_icon || item.data.poster,
sourceId: sourceId,
resumeTime: isResume ? item.progress : 0,
containerExtension: container
};
// For episodes, try to restore series data for next episode functionality
if (type === 'episode' && item.data) {
content.seriesId = item.data.seriesId || null;
content.currentSeason = item.data.currentSeason || null;
content.currentEpisode = item.data.currentEpisode || null;
// 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}`);
if (seriesInfo) {
content.seriesInfo = seriesInfo;
}
} catch (e) {
console.warn('[Dashboard] Could not fetch seriesInfo for next episode:', e);
}
}
}
// Switch to watch page
this.app.navigateTo('watch');
this.app.pages.watch.play(content, result.url);
}
} catch (err) {
console.error('[Dashboard] Playback failed:', err);
}
}
}
window.HomePage = HomePage;
+109
View File
@@ -0,0 +1,109 @@
/**
* Home Page Controller
*/
class LivePage {
constructor(app) {
this.app = app;
this.handleKeydown = this.handleKeydown.bind(this);
}
async init() {
// Load sources and channels on initial page load
await this.app.channelList.loadSources();
await this.app.channelList.loadChannels();
// Silently fetch EPG data for sidebar info
try {
await this.app.epgGuide.fetchEpgData();
// Clear cache so we don't get stale "null" results from initial render
this.app.channelList.clearProgramInfoCache();
// Update program info in existing DOM elements without re-rendering
this.updateProgramInfo();
} catch (err) {
console.warn('Background EPG fetch failed:', err);
}
}
/**
* Update "Now Playing" info in existing channel elements without blocking UI
*/
updateProgramInfo() {
const channelItems = Array.from(document.querySelectorAll('.channel-item'));
if (channelItems.length === 0) return;
// Build a map for O(1) channel lookups
const channelMap = new Map();
this.app.channelList.channels.forEach(c => channelMap.set(c.id, c));
// Process in small batches to avoid blocking UI
const BATCH_SIZE = 50;
let index = 0;
const processBatch = () => {
const end = Math.min(index + BATCH_SIZE, channelItems.length);
for (let i = index; i < end; i++) {
const item = channelItems[i];
const channelId = item.dataset.channelId;
const channel = channelMap.get(channelId);
if (channel) {
const programDiv = item.querySelector('.channel-program');
if (programDiv) {
const programTitle = this.app.channelList.getProgramInfo(channel);
programDiv.textContent = programTitle || '';
}
}
}
index = end;
if (index < channelItems.length) {
// Yield to browser before next batch
requestAnimationFrame(processBatch);
}
};
// Start processing
requestAnimationFrame(processBatch);
}
handleKeydown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.key) {
case 'ArrowUp':
// Check if player handles arrows for volume
if (this.app.player && !this.app.player.settings.arrowKeysChangeChannel) return;
e.preventDefault();
this.app.channelList.selectPrevChannel();
break;
case 'ArrowDown':
// Check if player handles arrows for volume
if (this.app.player && !this.app.player.settings.arrowKeysChangeChannel) return;
e.preventDefault();
this.app.channelList.selectNextChannel();
break;
}
}
async show() {
document.addEventListener('keydown', this.handleKeydown);
// Only reload if channels aren't already loaded
if (this.app.channelList.channels.length === 0) {
await this.app.channelList.loadSources();
await this.app.channelList.loadChannels();
}
}
hide() {
document.removeEventListener('keydown', this.handleKeydown);
}
}
window.LivePage = LivePage;
+400
View File
@@ -0,0 +1,400 @@
/**
* Movies Page Controller
* Handles VOD movie browsing and playback
*/
class MoviesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('movies-grid');
this.sourceSelect = document.getElementById('movies-source-select');
this.categorySelect = document.getElementById('movies-category-select');
this.searchInput = document.getElementById('movies-search');
this.movies = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredMovies = [];
this.isLoading = false;
this.observer = null;
this.favoriteIds = new Set(); // Track favorite movie IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadMovies();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadMovies();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('movies-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load movies if empty
if (this.movies.length === 0) {
await this.loadCategories();
await this.loadMovies();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'movie');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set(); // Track hidden categories
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'vod_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.vodCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadMovies() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.movies = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue; // Skip this source if category is from different source
}
}
const movies = await API.proxy.xtream.vodStreams(source.id, catId);
console.log(`[Movies] Source ${source.id}, Category ${catId || 'ALL'}: Got ${movies?.length || 0} movies`);
if (movies && Array.isArray(movies)) {
movies.forEach(m => {
// Skip movies from hidden categories
if (this.hiddenCategoryIds && this.hiddenCategoryIds.has(`${source.id}:${m.category_id}`)) {
return;
}
this.movies.push({
...m,
sourceId: source.id,
id: `${source.id}:${m.stream_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load movies from source ${source.id}:`, err.message);
}
}
console.log(`[Movies] Total loaded: ${this.movies.length} movies`);
this.filterAndRender();
} catch (err) {
console.error('Error loading movies:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading movies</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredMovies = this.movies.filter(m => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${m.sourceId}:${m.stream_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !m.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Movies] Displaying ${this.filteredMovies.length} of ${this.movies.length} movies`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredMovies.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'movies-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches (more to fill viewport)
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredMovies.slice(start, end);
console.log(`[Movies] Rendering batch ${this.currentBatch}: ${batch.length} cards (${start}-${end})`);
if (batch.length === 0) {
const loader = this.container.querySelector('.movies-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId;
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}` : '';
const isFav = this.favoriteIds.has(`${movie.sourceId}:${movie.stream_id}`);
card.innerHTML = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="movie-info">
<div class="movie-title">${movie.name}</div>
<div class="movie-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
// Card click plays movie, but not if clicking favorite button
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(movie, btn);
e.stopPropagation();
} else {
this.playMovie(movie);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.movies-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredMovies.length && loader) {
loader.style.display = 'none';
}
}
async playMovie(movie) {
try {
// Get stream URL for movie using the actual container extension from API
// Xtream API returns container_extension (e.g., 'mp4', 'mkv', 'avi')
const container = movie.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
this.app.pages.watch.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
sourceId: movie.sourceId,
categoryId: movie.category_id,
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing movie:', err);
}
}
async toggleFavorite(movie, btn) {
const favKey = `${movie.sourceId}:${movie.stream_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(movie.sourceId, movie.stream_id, 'movie');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.MoviesPage = MoviesPage;
+501
View File
@@ -0,0 +1,501 @@
/**
* Series Page Controller
* Handles TV series browsing and playback
*/
class SeriesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('series-grid');
this.sourceSelect = document.getElementById('series-source-select');
this.categorySelect = document.getElementById('series-category-select');
this.searchInput = document.getElementById('series-search');
this.detailsPanel = document.getElementById('series-details');
this.seasonsContainer = document.getElementById('series-seasons');
this.seriesList = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredSeries = [];
this.isLoading = false;
this.observer = null;
this.hiddenCategoryIds = new Set();
this.currentSeries = null;
this.favoriteIds = new Set(); // Track favorite series IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadSeries();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadSeries();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Back button
document.querySelector('.series-back-btn')?.addEventListener('click', () => {
this.hideDetails();
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('series-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Hide details panel when showing page
this.hideDetails();
// Load sources if not loaded
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load series if empty
if (this.seriesList.length === 0) {
await this.loadCategories();
await this.loadSeries();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'series');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set();
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'series_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.seriesCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load series categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadSeries() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.seriesList = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue;
}
}
const series = await API.proxy.xtream.series(source.id, catId);
console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`);
if (series && Array.isArray(series)) {
series.forEach(s => {
// Skip series from hidden categories
if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) {
return;
}
this.seriesList.push({
...s,
sourceId: source.id,
id: `${source.id}:${s.series_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load series from source ${source.id}:`, err.message);
}
}
console.log(`[Series] Total loaded: ${this.seriesList.length} series`);
this.filterAndRender();
} catch (err) {
console.error('Error loading series:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading series</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredSeries = this.seriesList.filter(s => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${s.sourceId}:${s.series_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredSeries.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'series-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredSeries.slice(start, end);
if (batch.length === 0) {
const loader = this.container.querySelector('.series-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(series => {
const card = document.createElement('div');
card.className = 'series-card';
card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId;
const poster = series.cover || '/img/placeholder.png';
const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`);
card.innerHTML = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="series-card-info">
<div class="series-title">${series.name}</div>
<div class="series-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(series, btn);
e.stopPropagation();
} else {
this.showSeriesDetails(series);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.series-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredSeries.length && loader) {
loader.style.display = 'none';
}
}
async showSeriesDetails(series) {
this.currentSeries = series;
// Show details panel
this.container.classList.add('hidden');
this.detailsPanel.classList.remove('hidden');
// Set header info
document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || '';
// Show loading
this.seasonsContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
// Fetch series info (seasons/episodes)
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
if (!info || !info.episodes) {
this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
return;
}
// Store series info for WatchPage
this.currentSeriesInfo = info;
// Render seasons and episodes
let html = '';
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
seasons.forEach(seasonNum => {
const episodes = info.episodes[seasonNum];
html += `
<div class="season-group">
<div class="season-header">
<span class="season-expander">${Icons.chevronDown}</span>
<span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
</div>
<div class="episode-list">
${episodes.map(ep => `
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
<span class="episode-number">E${ep.episode_num}</span>
<span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
<span class="episode-duration">${ep.duration || ''}</span>
</div>
`).join('')}
</div>
</div>`;
});
this.seasonsContainer.innerHTML = html;
// Add click handlers
this.seasonsContainer.querySelectorAll('.season-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.season-group').classList.toggle('collapsed');
});
});
this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => {
ep.addEventListener('click', () => this.playEpisode(ep));
});
} catch (err) {
console.error('Error loading series info:', err);
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
}
}
hideDetails() {
this.detailsPanel.classList.add('hidden');
this.container.classList.remove('hidden');
this.currentSeries = null;
}
async playEpisode(episodeEl) {
const episodeId = episodeEl.dataset.episodeId;
const sourceId = parseInt(episodeEl.dataset.sourceId);
const container = episodeEl.dataset.container || 'mp4';
// Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Season (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
this.app.pages.watch.play({
type: 'series',
id: episodeId,
title: this.currentSeries?.name || 'Series',
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.currentSeries?.cover,
description: this.currentSeries?.plot || '',
year: this.currentSeries?.year,
rating: this.currentSeries?.rating,
sourceId: sourceId,
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum,
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing episode:', err);
}
}
async toggleFavorite(series, btn) {
const favKey = `${series.sourceId}:${series.series_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(series.sourceId, series.series_id, 'series');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(series.sourceId, series.series_id, 'series');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.SeriesPage = SeriesPage;
+649
View File
@@ -0,0 +1,649 @@
/**
* Settings Page Controller
*/
class SettingsPage {
constructor(app) {
this.app = app;
this.tabs = document.querySelectorAll('.tabs .tab');
this.tabContents = document.querySelectorAll('.tab-content');
this.init();
}
init() {
// Tab switching
this.tabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Player settings
this.initPlayerSettings();
// Transcoding settings
this.initTranscodingSettings();
// User management (admin only)
this.initUserManagement();
}
initPlayerSettings() {
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
// Load current settings
if (this.app.player?.settings) {
arrowKeysToggle.checked = this.app.player.settings.arrowKeysChangeChannel;
overlayDurationInput.value = this.app.player.settings.overlayDuration;
defaultVolumeSlider.value = this.app.player.settings.defaultVolume;
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
}
// Arrow keys toggle
arrowKeysToggle.addEventListener('change', () => {
this.app.player.settings.arrowKeysChangeChannel = arrowKeysToggle.checked;
this.app.player.saveSettings();
});
// Overlay duration
overlayDurationInput.addEventListener('change', () => {
this.app.player.settings.overlayDuration = parseInt(overlayDurationInput.value) || 5;
this.app.player.saveSettings();
});
// Default volume slider
defaultVolumeSlider.addEventListener('input', () => {
const value = defaultVolumeSlider.value;
volumeValueDisplay.textContent = value + '%';
this.app.player.settings.defaultVolume = parseInt(value);
this.app.player.saveSettings();
});
// Remember volume toggle
rememberVolumeToggle.addEventListener('change', () => {
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
this.app.player.saveSettings();
});
// Auto-play next episode toggle
autoPlayNextToggle.addEventListener('change', () => {
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
this.app.player.saveSettings();
});
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) {
// Load saved value from player settings
epgRefreshSelect.value = this.app.player.settings.epgRefreshInterval || '24';
// Save on change - server will restart its sync timer via PUT /api/settings
epgRefreshSelect.addEventListener('change', () => {
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
this.app.player.saveSettings();
});
}
// Update last refreshed display
this.updateEpgLastRefreshed();
}
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
const maxResolutionSelect = document.getElementById('setting-max-resolution');
const qualitySelect = document.getElementById('setting-quality');
// Stream processing (use -tc suffix IDs from Transcoding tab)
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode-tc');
const forceTranscodeToggle = document.getElementById('setting-force-transcode-tc');
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
// User-Agent (Transcoding tab versions)
const userAgentSelect = document.getElementById('setting-user-agent-tc');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom-tc');
const customUaContainer = document.getElementById('custom-user-agent-container-tc');
// Fetch settings directly from API to avoid race condition with VideoPlayer
let s;
try {
s = await API.settings.get();
} catch (err) {
console.warn('[Settings] Failed to load settings from API, using player defaults:', err);
s = this.app.player?.settings || {};
}
if (hwEncoderSelect) hwEncoderSelect.value = s.hwEncoder || 'auto';
if (maxResolutionSelect) maxResolutionSelect.value = s.maxResolution || '1080p';
if (qualitySelect) qualitySelect.value = s.quality || 'medium';
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy === true;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode !== false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
// Event listeners for encoder settings
hwEncoderSelect?.addEventListener('change', () => {
this.app.player.settings.hwEncoder = hwEncoderSelect.value;
this.app.player.saveSettings();
});
maxResolutionSelect?.addEventListener('change', () => {
this.app.player.settings.maxResolution = maxResolutionSelect.value;
this.app.player.saveSettings();
});
qualitySelect?.addEventListener('change', () => {
this.app.player.settings.quality = qualitySelect.value;
this.app.player.saveSettings();
});
// Audio Mix Preset
const audioMixSelect = document.getElementById('setting-audio-mix');
if (audioMixSelect) {
audioMixSelect.value = s.audioMixPreset || 'auto';
audioMixSelect.addEventListener('change', () => {
this.app.player.settings.audioMixPreset = audioMixSelect.value;
this.app.player.saveSettings();
});
}
// Upscaling Settings
const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled');
const upscaleMethodSelect = document.getElementById('setting-upscale-method');
const upscaleTargetSelect = document.getElementById('setting-upscale-target');
const upscaleMethodContainer = document.getElementById('upscale-method-container');
const upscaleTargetContainer = document.getElementById('upscale-target-container');
// Helper to toggle upscale options visibility
const toggleUpscaleOptions = (enabled) => {
if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none';
if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none';
};
// Load upscaling settings
if (upscaleEnabledToggle) {
upscaleEnabledToggle.checked = s.upscaleEnabled || false;
toggleUpscaleOptions(upscaleEnabledToggle.checked);
}
if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware';
if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p';
// Upscaling event handlers
upscaleEnabledToggle?.addEventListener('change', () => {
this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked;
this.app.player.saveSettings();
toggleUpscaleOptions(upscaleEnabledToggle.checked);
});
upscaleMethodSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleMethod = upscaleMethodSelect.value;
this.app.player.saveSettings();
});
upscaleTargetSelect?.addEventListener('change', () => {
this.app.player.settings.upscaleTarget = upscaleTargetSelect.value;
this.app.player.saveSettings();
});
// Stream processing toggles
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
this.app.player.saveSettings();
});
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceVideoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
});
streamFormatSelect?.addEventListener('change', () => {
this.app.player.settings.streamFormat = streamFormatSelect.value;
this.app.player.saveSettings();
});
// User-Agent handlers
const toggleCustomInput = () => {
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
};
userAgentSelect?.addEventListener('change', () => {
this.app.player.settings.userAgentPreset = userAgentSelect.value;
this.app.player.saveSettings();
toggleCustomInput();
});
userAgentCustomInput?.addEventListener('change', () => {
this.app.player.settings.userAgentCustom = userAgentCustomInput.value;
this.app.player.saveSettings();
});
}
/**
* Load and display hardware info in Transcoding tab
*/
async loadHardwareInfo() {
const container = document.getElementById('hw-info-container');
if (!container) return;
try {
const response = await fetch('/api/settings/hw-info');
if (!response.ok) throw new Error('Failed to fetch hardware info');
const hwInfo = await response.json();
const detected = [];
// Only show detected hardware
if (hwInfo.nvidia?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ NVIDIA</span>
<span class="hw-name">${hwInfo.nvidia.name}</span>
</div>`);
}
if (hwInfo.amf?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ AMD</span>
<span class="hw-name">${hwInfo.amf.name || 'Available'}</span>
</div>`);
}
if (hwInfo.qsv?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ Intel QSV</span>
<span class="hw-name">Available</span>
</div>`);
}
if (hwInfo.vaapi?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ VAAPI</span>
<span class="hw-name">${hwInfo.vaapi.device || 'Available'}</span>
</div>`);
}
let html;
if (detected.length > 0) {
html = `<div class="hw-info-grid">${detected.join('')}</div>`;
html += `<p class="hint" style="margin-top: var(--space-sm);">Recommended encoder: <strong>${hwInfo.recommended}</strong></p>`;
} else {
html = `<p class="hint">No GPU acceleration detected. Using software encoding.</p>`;
}
container.innerHTML = html;
} catch (err) {
console.error('Error loading hardware info:', err);
container.innerHTML = '<p class="hint error">Failed to load hardware info</p>';
}
}
initUserManagement() {
// User tab visibility is handled in show() method
// when currentUser is available
// Handle add user form
const addUserForm = document.getElementById('add-user-form');
if (addUserForm) {
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
try {
await API.users.create({ username, password, role });
alert('User created successfully!');
addUserForm.reset();
this.loadUsers();
} catch (err) {
alert('Error creating user: ' + err.message);
}
});
}
}
async loadUsers() {
const userList = document.getElementById('user-list');
if (!userList) return;
try {
const users = await API.users.getAll();
// Store users in memory for easy access during edit
this.users = users;
if (users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="hint">No users found</td></tr>';
return;
}
userList.innerHTML = users.map(user => {
const isSSO = !!user.oidcId;
const typeBadge = isSSO
? '<span class="user-badge user-badge-sso">SSO</span>'
: '<span class="user-badge user-badge-local">Local</span>';
const roleBadge = user.role === 'admin'
? '<span class="user-badge user-badge-admin">Admin</span>'
: '<span class="user-badge user-badge-viewer">Viewer</span>';
return `
<tr>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<strong>${user.username}</strong>
${typeBadge}
</div>
</td>
<td>${user.email || '<span class="hint">-</span>'}</td>
<td>${roleBadge}</td>
<td>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.openEditUserModal(${user.id})">Edit</button>
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
</td>
</tr>
`}).join('');
} catch (err) {
console.error('Error loading users:', err);
userList.innerHTML = '<tr><td colspan="5" class="hint">Error loading users</td></tr>';
}
}
openEditUserModal(userId) {
console.log('openEditUserModal called with ID:', userId, 'Type:', typeof userId);
console.log('Current users list:', this.users);
const user = this.users.find(u => u.id === userId);
if (!user) {
console.error('User not found in this.users cache!');
console.log('Available IDs:', this.users.map(u => u.id));
return;
}
console.log('User found:', user);
const modal = document.getElementById('edit-user-modal');
console.log('Modal element:', modal);
if (!modal) {
console.error('CRITICAL: Modal element #edit-user-modal not found in DOM!');
alert('Error: Modal not found. Please refresh the page.');
return;
}
const isSSO = !!user.oidcId;
console.log('Is SSO user:', isSSO);
// Populate form with null checks
try {
const editId = document.getElementById('edit-user-id');
const editUsername = document.getElementById('edit-username');
const editEmail = document.getElementById('edit-email');
const editRole = document.getElementById('edit-role');
const editPassword = document.getElementById('edit-password');
console.log('Form elements found:', { editId, editUsername, editEmail, editRole, editPassword });
if (editId) editId.value = user.id;
if (editUsername) editUsername.value = user.username;
if (editEmail) editEmail.value = user.email || '';
if (editRole) editRole.value = user.role;
if (editPassword) editPassword.value = '';
// Handle SSO specific UI
const passwordHint = document.getElementById('edit-password-hint');
const oidcGroup = document.getElementById('oidc-info-group');
const oidcIdDisplay = document.getElementById('edit-oidc-id');
if (isSSO) {
if (editPassword) {
editPassword.disabled = true;
editPassword.placeholder = "Managed by SSO Provider";
}
if (passwordHint) passwordHint.textContent = "Password cannot be changed for SSO users.";
if (oidcGroup) oidcGroup.classList.remove('hidden');
if (oidcIdDisplay) oidcIdDisplay.textContent = user.oidcId;
} else {
if (editPassword) {
editPassword.disabled = false;
editPassword.placeholder = "Leave blank to keep current";
}
if (passwordHint) passwordHint.textContent = "Optional. Leave blank to keep unchanged.";
if (oidcGroup) oidcGroup.classList.add('hidden');
}
// Show modal
console.log('Adding active class to modal...');
modal.classList.add('active');
console.log('Modal classes after add:', modal.classList.toString());
// Setup Close/Cancel handlers (once)
this.setupModalHandlers(modal);
console.log('Modal should now be visible!');
} catch (err) {
console.error('Error populating modal:', err);
alert('Error opening edit modal: ' + err.message);
}
}
setupModalHandlers(modal) {
if (this.modalHandlersSetup) return;
const closeBtn = document.getElementById('edit-user-close');
const cancelBtn = document.getElementById('edit-user-cancel');
const saveBtn = document.getElementById('edit-user-save');
const closeModal = () => modal.classList.remove('active');
closeBtn.onclick = closeModal;
cancelBtn.onclick = closeModal;
// Click outside to close
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
// Save Handler
saveBtn.onclick = async () => {
const userId = document.getElementById('edit-user-id').value;
const updates = {
username: document.getElementById('edit-username').value,
role: document.getElementById('edit-role').value
};
const newPassword = document.getElementById('edit-password').value;
if (newPassword && !document.getElementById('edit-password').disabled) {
updates.password = newPassword;
}
try {
await API.users.update(userId, updates);
// alert('User updated successfully!'); // Optional: Replace with toast?
closeModal();
this.loadUsers();
} catch (err) {
alert('Error updating user: ' + err.message);
}
};
this.modalHandlersSetup = true;
}
async deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
await API.users.delete(userId);
this.loadUsers();
} catch (err) {
alert('Error deleting user: ' + err.message);
}
}
switchTab(tabName) {
this.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
this.tabContents.forEach(c => c.classList.toggle('active', c.id === `tab-${tabName}`));
// Load content browser when switching to that tab
if (tabName === 'content') {
this.app.sourceManager.loadContentSources();
}
// Load users when switching to users tab
if (tabName === 'users') {
this.loadUsers();
}
// Load hardware info when switching to transcode tab
if (tabName === 'transcode') {
this.loadHardwareInfo();
}
}
async show() {
// Show users tab for admin
if (this.app.currentUser && this.app.currentUser.role === 'admin') {
const usersTab = document.getElementById('users-tab');
if (usersTab) {
usersTab.style.display = 'block';
}
}
// Load sources when page is shown
await this.app.sourceManager.loadSources();
// Refresh ALL player settings from server
if (this.app.player?.settings) {
const s = this.app.player.settings;
// Player settings
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
const defaultVolumeSlider = document.getElementById('setting-default-volume');
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
const streamFormatSelect = document.getElementById('setting-stream-format');
if (arrowKeysToggle) arrowKeysToggle.checked = s.arrowKeysChangeChannel;
if (overlayDurationInput) overlayDurationInput.value = s.overlayDuration;
if (defaultVolumeSlider) defaultVolumeSlider.value = s.defaultVolume;
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom');
const customUaContainer = document.getElementById('custom-user-agent-container');
if (userAgentSelect) {
userAgentSelect.value = s.userAgentPreset || 'chrome';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect.value === 'custom' ? 'flex' : 'none';
}
}
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
}
// Update EPG last refreshed display
this.updateEpgLastRefreshed();
}
/**
* Update the EPG last refreshed display
*/
async updateEpgLastRefreshed() {
const display = document.getElementById('epg-last-refreshed');
if (!display) return;
try {
// Fetch last sync time from server
const response = await fetch('/api/settings/sync-status');
if (!response.ok) throw new Error('Failed to fetch sync status');
const data = await response.json();
if (data.lastSyncTime) {
const lastRefreshTime = new Date(data.lastSyncTime);
// Format as relative time or absolute
const now = new Date();
const diffMs = now - lastRefreshTime;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
let text;
if (diffMins < 1) {
text = 'Just now';
} else if (diffMins < 60) {
text = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
} else if (diffHours < 24) {
text = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else {
// Use absolute time for older refreshes
text = lastRefreshTime.toLocaleString();
}
display.textContent = text;
display.title = lastRefreshTime.toLocaleString(); // Full timestamp on hover
} else {
display.textContent = 'Never';
display.title = 'Sync has not run yet since server started';
}
} catch (err) {
console.error('Error fetching sync status:', err);
display.textContent = 'Unknown';
display.title = 'Could not fetch sync status';
}
}
hide() {
// Page is hidden
}
}
window.SettingsPage = SettingsPage;
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iniciar sesión — KiraTV</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
background: var(--color-bg-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-box {
background: var(--color-bg-secondary);
padding: var(--space-2xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 420px;
border: 1px solid var(--color-border);
}
.login-logo {
text-align: center;
margin-bottom: var(--space-2xl);
}
.login-logo .logo-icon {
width: 64px;
height: 64px;
margin: 0 auto var(--space-md);
color: var(--color-accent);
}
.login-logo h1 {
font-family: 'Century Gothic', sans-serif;
font-size: 28px;
font-weight: 500;
color: var(--color-text-primary);
margin: 0;
}
.login-logo .brand-accent {
color: var(--color-accent);
}
.login-logo p {
color: var(--color-text-secondary);
margin: var(--space-sm) 0 0 0;
font-size: 14px;
}
.form-group {
margin-bottom: var(--space-lg);
}
.form-group label {
display: block;
margin-bottom: var(--space-sm);
color: var(--color-text-primary);
font-weight: 500;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 15px;
font-family: 'Outfit', sans-serif;
transition: all 0.2s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--color-accent);
background: var(--color-bg-hover);
}
.form-group input::placeholder {
color: var(--color-text-muted);
}
.btn-login {
width: 100%;
padding: 14px;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
font-family: 'Outfit', sans-serif;
cursor: pointer;
transition: all 0.2s;
}
.btn-login:hover {
background: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
color: var(--color-error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
border: 1px solid rgba(239, 68, 68, 0.2);
font-size: 14px;
display: none;
}
.error-message.show {
display: block;
}
.setup-message {
background: rgba(99, 102, 241, 0.1);
color: var(--color-accent);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
border: 1px solid var(--color-accent-dim);
font-size: 14px;
display: none;
}
.setup-message.show {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="login-logo">
<svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6"
fill="currentColor">
<path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg>
<h1>KiraTV</h1>
<p id="login-subtitle">Inicia sesión para continuar</p>
</div>
<div class="error-message" id="error-message"></div>
<div class="setup-message" id="setup-message">
Bienvenido. Crea tu cuenta de administrador para empezar.
</div>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username"
placeholder="Enter your username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password"
minlength="6" placeholder="Enter your password">
</div>
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
</form>
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
<span
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
<div
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
</div>
</div>
<button id="btn-sso-login" class="btn-login"
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
style="width: 20px; height: 20px;">
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg>
Sign in with SSO
</button>
<style>
#btn-sso-login:hover {
background: var(--color-bg-tertiary) !important;
border-color: var(--color-accent) !important;
color: var(--color-accent) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>
</div>
</div>
<script>
// Start SSO Login
document.getElementById('btn-sso-login').addEventListener('click', () => {
window.location.href = '/api/auth/oidc/login';
});
// Check for URL Error params
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
if (error) {
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = error;
errorMessage.classList.add('show');
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
}
// Check if setup is required
let isSetupMode = false;
async function checkSetupRequired() {
try {
const response = await fetch('/api/auth/setup-required');
const data = await response.json();
if (data.setupRequired) {
isSetupMode = true;
document.getElementById('login-subtitle').textContent = 'Create Admin Account';
document.getElementById('submit-btn').textContent = 'Create Account';
document.getElementById('setup-message').classList.add('show');
document.getElementById('password').placeholder = 'Minimum 6 characters';
// Hide SSO in setup mode
document.querySelector('.sso-divider').style.display = 'none';
document.getElementById('btn-sso-login').style.display = 'none';
}
} catch (err) {
console.error('Error checking setup status:', err);
}
}
// Handle form submission
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const submitBtn = document.getElementById('submit-btn');
const errorMessage = document.getElementById('error-message');
// Clear previous errors
errorMessage.classList.remove('show');
errorMessage.textContent = '';
// Disable button
submitBtn.disabled = true;
submitBtn.textContent = 'Please wait...';
try {
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Authentication failed');
}
// Store token
localStorage.setItem('authToken', data.token);
// Redirect to main app (replace history to prevent back button issues)
window.location.replace('/');
} catch (err) {
errorMessage.textContent = err.message;
errorMessage.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = isSetupMode ? 'Create Account' : 'Sign In';
}
});
// Check setup status on load
checkSetupRequired();
</script>
</body>
</html>
+268
View File
@@ -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
};
+468
View File
@@ -0,0 +1,468 @@
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,
streamFormat: 'm3u8',
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 };
+216
View File
@@ -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
};
+230
View File
@@ -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);
});
+163
View File
@@ -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
+25
View File
@@ -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");
};
+294
View File
@@ -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;
+329
View File
@@ -0,0 +1,329 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
// 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 hidden = [];
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
});
// Query Categories
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`;
const params = [];
if (sourceId) {
catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`;
const sid = parseInt(sourceId);
params.push(sid);
}
const hiddenCats = db.prepare(catQuery).all(...params);
const hiddenItems = db.prepare(itemQuery).all(...params);
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' });
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' });
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 });
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 db = getDb();
// Prepare statements once
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 = ?');
// Cascading statements (hide all children of a category)
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') {
// Hide the category
hideCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Hide individual item
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 db = getDb();
// Prepare statements once
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 = ?');
// Cascading statements (show all children of a category)
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') {
// Show the category
showCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Show individual item
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' });
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
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' });
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
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' });
}
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
)
ORDER BY p.added_at DESC
LIMIT ?
`).all(type, parseInt(limit));
// Parse JSON data for each item
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;
+66
View File
@@ -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;
+112
View File
@@ -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;
+236
View File
@@ -0,0 +1,236 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const streamInput = require('../utils/streamInput');
/**
* 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 sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
const effectiveTimeout = sizing.heavy ? Math.max(timeout, 45000) : timeout;
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'
];
if (referer) {
args.push('-referer', referer);
}
args.push(
'-print_format', 'json',
'-show_streams',
'-show_format',
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
url
);
const proc = spawn(ffprobePath, args);
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error('Probe timeout'));
}, effectiveTimeout);
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;
+823
View File
@@ -0,0 +1,823 @@
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');
// 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 = await sources.getById(req.params.sourceId);
if (!source || 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 = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
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 = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
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 = await sources.getById(req.params.sourceId);
if (!source || 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 || 'm3u8';
// 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 = await sources.getById(sourceId);
if (!source || 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 = await sources.getById(req.params.sourceId);
if (!source || 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 = 'm3u8' } = 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 = await sources.getById(sourceId);
if (!source || (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 = await sources.getById(req.params.sourceId);
if (!source || 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;
+124
View File
@@ -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;
+111
View File
@@ -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;
+281
View File
@@ -0,0 +1,281 @@
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');
// Get all sources
router.get('/', async (req, res) => {
try {
const allSources = await sources.getAll();
// Don't expose passwords in list view
const sanitized = allSources.map(s => ({
...s,
password: s.password ? '••••••••' : null
}));
res.json(sanitized);
} catch (err) {
console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Get sync status for all sources
router.get('/status', async (req, res) => {
try {
const { getDb } = require('../db/sqlite');
const db = getDb();
const statuses = db.prepare('SELECT * FROM sync_status').all();
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);
res.json(typeSources);
} catch (err) {
console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// 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' });
}
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' });
}
const source = await sources.create({ type, name, url, username, password });
// Trigger Sync
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' });
}
const { name, url, username, password } = req.body;
const updated = await sources.update(req.params.id, {
name: name || existing.name,
url: url || existing.url,
username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password
});
// Trigger Sync (if critical fields changed? safely just trigger it)
syncService.syncSource(parseInt(req.params.id)).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);
const existing = await sources.getById(sourceId);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
// Cascade delete: Clean up SQLite data for 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`);
// Delete source config and related hidden items (favorites handled by db.js)
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 updated = await sources.toggleEnabled(req.params.id);
if (!updated) {
return res.status(404).json({ error: 'Source not found' });
}
// If enabled, trigger sync
if (updated.enabled) {
syncService.syncSource(parseInt(req.params.id)).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);
const source = await sources.getById(id);
if (!source) return res.status(404).json({ error: 'Source not found' });
// Trigger sync (async)
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 (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('<tv') || text.includes('<?xml');
res.json({ success: isValid, message: isValid ? 'Valid EPG XML' : 'Invalid EPG format' });
}
} catch (err) {
console.error('Error testing source:', err);
res.json({ success: false, error: err.message });
}
});
// Estimate M3U playlist size (for large playlist warning)
const M3U_LARGE_THRESHOLD = 50000;
// Estimate by URL (for new sources before creation)
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Only M3U sources need estimation
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for URL...`);
const count = await m3uParser.countEntries(url);
console.log(`[Sources] M3U estimate: ${count} entries`);
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 });
}
});
// Estimate by source ID (for existing sources)
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' });
}
// Only M3U sources need estimation
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
const count = await m3uParser.countEntries(source.url);
console.log(`[Sources] M3U estimate: ${count} entries`);
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 - sync all enabled sources
router.post('/sync-all', async (req, res) => {
try {
// Trigger global sync (async - don't wait for completion)
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' });
}
});
module.exports = router;
+58
View File
@@ -0,0 +1,58 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
/**
* Subtitle extraction endpoint
* GET /api/subtitle?url=...&index=...
*
* Extracts a specific subtitle track and converts it to WebVTT on the fly.
*/
router.get('/', (req, res) => {
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;
+381
View File
@@ -0,0 +1,381 @@
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');
const streamInput = require('../utils/streamInput');
/**
* Probe a stream URL with FFprobe to get its total duration in seconds.
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
*/
function probeDuration(url, ffprobePath, userAgent) {
return new Promise((resolve) => {
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format=duration',
'-user_agent', userAgent || 'Mozilla/5.0',
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
];
if (referer) {
args.push('-referer', referer);
}
args.push(url);
const timeoutMs = sizing.heavy ? 45000 : 8000;
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (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)
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
let ready = await session.waitForPlaylist(waitTimeout);
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
await transcodeSession.removeSession(session.id);
const swSession = await transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto',
upscaleEnabled: false,
videoMode: videoMode,
videoCodec: videoCodec,
audioCodec: audioCodec,
audioChannels: audioChannels
});
await swSession.start();
ready = await swSession.waitForPlaylist(waitTimeout);
if (ready) {
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
return res.json({
sessionId: swSession.id,
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
status: swSession.status,
durationSec: durationSec || 0
});
}
await transcodeSession.removeSession(swSession.id);
}
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}`);
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
// 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,
];
if (referer) {
args.push('-referer', referer);
}
args.push(
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
// 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
);
args.push(
// 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;
+186
View File
@@ -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;
+151
View File
@@ -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
};
+448
View File
@@ -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
};
+283
View File
@@ -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
};
+316
View File
@@ -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>} 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 };
+133
View File
@@ -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 };
@@ -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 = 30 * 60 * 1000; // 30 minutes idle timeout
const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
/**
* 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
};
+574
View File
@@ -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();
+787
View File
@@ -0,0 +1,787 @@
/**
* 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');
const streamInput = require('../utils/streamInput');
// 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 sizing = streamInput.probeSizingForUrl(this.url);
const referer = streamInput.deriveRefererFromUrl(this.url);
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', this.options.userAgent,
];
if (referer) {
args.push('-referer', referer);
}
// Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') {
this.addHwAccelInputArgs(args, encoder);
}
// Input options (common)
args.push(
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
'-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':
// Solo registramos el dispositivo VAAPI globalmente.
// Se usa decodificación SOFTWARE para máxima compatibilidad con
// cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
// puede no tener soporte de decodificación para todos ellos.
// El upload a memoria VAAPI se hace en el filtro de vídeo.
args.push('-vaapi_device', '/dev/dri/renderD128');
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':
// Software decode → scale → nv12 → hwupload → VAAPI encode
// format=nv12 ANTES de hwupload es obligatorio para Intel iGPU en LXC
return `scale=-2:${height},format=nv12,hwupload`;
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. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
// -bf 3 puede causar problemas en algunos drivers Intel; se omite.
args.push(
'-c:v', 'h264_vaapi',
'-global_quality', String(qp)
);
}
/**
* 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
};
+161
View File
@@ -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 };
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/**
* Opciones de entrada HTTP para ffprobe/ffmpeg con orígenes tipo Xtream / Jellyfin (Dispatcharr).
* Muchos backends exigen Referer/origin coherentes además del User-Agent.
*/
function deriveRefererFromUrl(inputUrl) {
try {
const u = new URL(inputUrl);
return `${u.origin}/`;
} catch {
return null;
}
}
/** VOD pesado (Remux Jellyfin MKV/MP4, GOP largo) — probes más grandes. */
function isHeavyVodUrl(url) {
if (!url || typeof url !== 'string') return false;
const u = url.toLowerCase();
if (u.includes('/movie/') || u.includes('/series/')) return true;
if (/\.mkv\b/i.test(url) || /\.mp4\b/i.test(url)) return true;
return false;
}
function probeSizingForUrl(url) {
if (isHeavyVodUrl(url)) {
/** Microseconds; VOD Jellyfin/remux pueden necesitar leer bastante antes del analyze */
return { probesize: '32000000', analyzeduration: '45000000', heavy: true };
}
return { probesize: '8000000', analyzeduration: '8000000', heavy: false };
}
module.exports = {
deriveRefererFromUrl,
isHeavyVodUrl,
probeSizingForUrl
};