@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
user_data/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
.vscode
|
||||||
|
transcode-cache/
|
||||||
@@ -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"]
|
||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#000066"/>
|
||||||
|
<path fill="#ffffff" d="M14 48V14h10l14 16V14h10v34H38L24 32v16H14z"/>
|
||||||
|
<path stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" fill="none" d="M36 20c8 0 14 6 14 14s-6 14-14 14"/>
|
||||||
|
<path stroke="#ffffff" stroke-width="2" stroke-linecap="round" fill="none" opacity=".9" d="M34 16c11 0 20 9 20 20s-9 20-20 20"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -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 = 'ts') =>
|
||||||
|
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;
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
|
||||||
|
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
|
||||||
|
await this.channelList.loadSources();
|
||||||
|
await this.channelList.loadChannels();
|
||||||
|
|
||||||
|
// 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(() => { });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
function showToast(msg, type, durationMs) {
|
||||||
|
let container = document.getElementById('kiratv-desktop-toast');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'kiratv-desktop-toast';
|
||||||
|
Object.assign(container.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '24px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: '99999',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
});
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
Object.assign(toast.style, {
|
||||||
|
background: type === 'error' ? '#c0392b' : '#1a7f4a',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
|
||||||
|
opacity: '1',
|
||||||
|
transition: 'opacity 0.4s'
|
||||||
|
});
|
||||||
|
toast.textContent = msg;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 450);
|
||||||
|
}, durationMs || 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapDesktopWrapperUrl(urlLike) {
|
||||||
|
try {
|
||||||
|
const u = new URL(String(urlLike), window.location.origin);
|
||||||
|
const wrappedPath = u.pathname;
|
||||||
|
if (
|
||||||
|
wrappedPath === '/api/transcode' ||
|
||||||
|
wrappedPath === '/api/remux' ||
|
||||||
|
wrappedPath === '/api/proxy/stream'
|
||||||
|
) {
|
||||||
|
const original = u.searchParams.get('url');
|
||||||
|
if (original) return original;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
return urlLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolutePlaybackUrl(streamUrl) {
|
||||||
|
if (!streamUrl) return '';
|
||||||
|
const raw = String(streamUrl).trim();
|
||||||
|
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
|
||||||
|
if (/^https?:\/\//i.test(s)) return s;
|
||||||
|
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNativePlaybackUrl(streamUrl) {
|
||||||
|
const abs = absolutePlaybackUrl(streamUrl);
|
||||||
|
try {
|
||||||
|
const u = new URL(abs);
|
||||||
|
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
|
||||||
|
const r = await fetch(abs, { credentials: 'include' });
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
if (j && typeof j.url === 'string' && j.url.trim()) {
|
||||||
|
return j.url.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* usar abs */
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authBearerPayload() {
|
||||||
|
try {
|
||||||
|
const t = localStorage.getItem('authToken');
|
||||||
|
return t && t.length > 0 ? { authorizationBearer: t } : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authBearerPayloadFor(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
if (u.origin !== window.location.origin) return {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return authBearerPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementToBounds(el) {
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: Math.round(r.left),
|
||||||
|
top: Math.round(r.top),
|
||||||
|
width: Math.max(1, Math.round(r.width)),
|
||||||
|
height: Math.max(1, Math.round(r.height))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
|
||||||
|
*/
|
||||||
|
function getEmbedBounds() {
|
||||||
|
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
|
||||||
|
if (window.kiratvDesktop.embedUsesNativePanel) return null;
|
||||||
|
|
||||||
|
const watchPage = document.getElementById('page-watch');
|
||||||
|
if (watchPage && watchPage.classList.contains('active')) {
|
||||||
|
const videoEl = document.getElementById('watch-video');
|
||||||
|
if (videoEl) return elementToBounds(videoEl);
|
||||||
|
const section = document.querySelector('.watch-video-section');
|
||||||
|
if (section) return elementToBounds(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePage = document.getElementById('page-live');
|
||||||
|
if (livePage && livePage.classList.contains('active')) {
|
||||||
|
const ps = document.querySelector('.player-section');
|
||||||
|
if (ps) return elementToBounds(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ps = document.querySelector('.player-section');
|
||||||
|
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
|
||||||
|
const wv = document.getElementById('watch-video');
|
||||||
|
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function embedUsesNativePanel() {
|
||||||
|
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEmbeddedUiClass(active) {
|
||||||
|
const native = embedUsesNativePanel();
|
||||||
|
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
|
||||||
|
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
|
||||||
|
*/
|
||||||
|
function shellChromeModeFromHash() {
|
||||||
|
const raw = location.hash || '#home';
|
||||||
|
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
|
||||||
|
if (h === 'live') return 'liveSplit';
|
||||||
|
if (h === 'watch') return 'vodSplit';
|
||||||
|
return 'fullWeb';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncShellChrome() {
|
||||||
|
const d = window.kiratvDesktop;
|
||||||
|
if (typeof d?.setShellChromeMode !== 'function') return;
|
||||||
|
const mode = shellChromeModeFromHash();
|
||||||
|
d.setShellChromeMode({ mode }).catch(() => {});
|
||||||
|
|
||||||
|
if (embedUsesNativePanel() && mode === 'fullWeb') {
|
||||||
|
d.stopExternalMpv?.().catch(() => {});
|
||||||
|
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
|
||||||
|
window.kiratvDesktopClearEmbeddedChrome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEmbedBoundsSync() {
|
||||||
|
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
|
||||||
|
if (embedUsesNativePanel()) return;
|
||||||
|
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
|
||||||
|
|
||||||
|
let t = null;
|
||||||
|
function flushBounds() {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => {
|
||||||
|
const b = getEmbedBounds();
|
||||||
|
if (!b) return;
|
||||||
|
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
|
||||||
|
}, 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', flushBounds);
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(flushBounds);
|
||||||
|
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
|
||||||
|
try {
|
||||||
|
ro.observe(el);
|
||||||
|
} catch (_) {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', flushBounds);
|
||||||
|
|
||||||
|
flushBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEmbeddedPlayback() {
|
||||||
|
const d = window.kiratvDesktop;
|
||||||
|
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playInMpv(absUrl, extraArgs) {
|
||||||
|
const d = window.kiratvDesktop;
|
||||||
|
if (!d?.playExternalMpv) {
|
||||||
|
showToast('Error: sin puente escritorio', 'error');
|
||||||
|
return { ok: false, error: 'Sin puente escritorio' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
|
||||||
|
|
||||||
|
if (useEmbeddedPlayback()) {
|
||||||
|
const native = embedUsesNativePanel();
|
||||||
|
|
||||||
|
let bounds = null;
|
||||||
|
if (!native) {
|
||||||
|
bounds = getEmbedBounds();
|
||||||
|
if (!bounds || bounds.width < 2 || bounds.height < 2) {
|
||||||
|
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
|
||||||
|
return { ok: false, error: 'bounds vídeo vacíos' };
|
||||||
|
}
|
||||||
|
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
|
||||||
|
try {
|
||||||
|
await d.setVideoHostBounds(bounds);
|
||||||
|
} catch (_) {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmbeddedUiClass(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
url: nativeUrl,
|
||||||
|
extraArgs: extraArgs || [],
|
||||||
|
...authBearerPayloadFor(nativeUrl)
|
||||||
|
};
|
||||||
|
if (!native && bounds) payload.bounds = bounds;
|
||||||
|
|
||||||
|
const result = await d.playEmbeddedMpv(payload);
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
setEmbeddedUiClass(false);
|
||||||
|
const msg = result?.error || 'mpv no pudo arrancar';
|
||||||
|
console.error('[KiraTV Desktop]', msg);
|
||||||
|
showToast('⚠ ' + msg, 'error', 6000);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
|
||||||
|
|
||||||
|
const result = await d.playExternalMpv({
|
||||||
|
url: nativeUrl,
|
||||||
|
extraArgs: extraArgs || [],
|
||||||
|
...authBearerPayloadFor(nativeUrl)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
const msg = result?.error || 'mpv no pudo arrancar';
|
||||||
|
console.error('[KiraTV Desktop]', msg);
|
||||||
|
showToast(
|
||||||
|
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
|
||||||
|
'error',
|
||||||
|
6000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function whenReady(cb) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', cb, { once: true });
|
||||||
|
} else {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
whenReady(() => {
|
||||||
|
if (window.kiratvDesktop?.isDesktopApp) {
|
||||||
|
document.body.classList.add('kiratv-desktop-app');
|
||||||
|
}
|
||||||
|
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
|
||||||
|
initEmbedBoundsSync();
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', syncShellChrome);
|
||||||
|
setTimeout(syncShellChrome, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.kiratvDesktopClearEmbeddedChrome = function () {
|
||||||
|
setEmbeddedUiClass(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
|
||||||
|
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
|
||||||
|
|
||||||
|
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
|
||||||
|
return playInMpv(absoluteUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
|
||||||
|
return playInMpv(absoluteUrl, extraArgs);
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* 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 se han encontrado películas</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 rawPoster = movie.stream_icon || movie.cover || '';
|
||||||
|
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/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,
|
||||||
|
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* 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 se han encontrado series</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 rawPoster = series.cover || '';
|
||||||
|
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/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
|
||||||
|
const rawCover = series.cover || '';
|
||||||
|
document.getElementById('series-poster').src = rawCover.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawCover)}` : (rawCover || '/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 hay episodios</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">Temporada ${seasonNum} (${episodes.length} episodios)</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 || `Episodio ${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 al cargar episodios</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(/Temporada (\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 || `Episodio ${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,
|
||||||
|
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
|
||||||
|
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;
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
/**
|
||||||
|
* 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 || 'ts';
|
||||||
|
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('Usuario creado correctamente.');
|
||||||
|
addUserForm.reset();
|
||||||
|
this.loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error al crear usuario: ' + 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 al abrir edición: ' + 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 al actualizar usuario: ' + 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 al eliminar usuario: ' + 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refrescar listas al volver a Fuentes (evita lista vacía tras crear/asignar)
|
||||||
|
if (tabName === 'sources') {
|
||||||
|
this.app.sourceManager.loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || 'ts';
|
||||||
|
|
||||||
|
// 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 = 'Hace un momento';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
text = `Hace ${diffMins} min`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
text = `Hace ${diffHours} h`;
|
||||||
|
} else {
|
||||||
|
text = lastRefreshTime.toLocaleString('es-ES');
|
||||||
|
}
|
||||||
|
|
||||||
|
display.textContent = text;
|
||||||
|
display.title = lastRefreshTime.toLocaleString('es-ES');
|
||||||
|
} else {
|
||||||
|
display.textContent = 'Nunca';
|
||||||
|
display.title = 'La sincronización aún no se ha ejecutado';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching sync status:', err);
|
||||||
|
display.textContent = '—';
|
||||||
|
display.title = 'No se pudo obtener el estado de sincronización';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
// Page is hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SettingsPage = SettingsPage;
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
|
||||||
|
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;
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!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="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700&family=Montserrat:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--navy: #000066;
|
||||||
|
--navy-mid: #000044;
|
||||||
|
--gold: #e8d5b5;
|
||||||
|
--gold-hover: #f5ead8;
|
||||||
|
--text: #ffffff;
|
||||||
|
--muted: rgba(255, 255, 255, 0.72);
|
||||||
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 80% at 50% -30%, rgba(255, 255, 255, 0.06), transparent 50%),
|
||||||
|
radial-gradient(ellipse 70% 50% at 110% 40%, rgba(0, 0, 102, 0.55), transparent),
|
||||||
|
linear-gradient(165deg, var(--navy) 0%, var(--navy-mid) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 2.25rem 2rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(0, 0, 70, 0.55);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand-img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: min(300px, 100%);
|
||||||
|
height: auto;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 16px;
|
||||||
|
filter: drop-shadow(0 16px 40px rgba(0, 0, 0, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-subtitle {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(8, 12, 20, 0.65);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(232, 213, 181, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 213, 181, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: rgba(168, 176, 196, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--gold) 0%, #a67c1f 100%);
|
||||||
|
color: var(--navy);
|
||||||
|
transition: transform 0.15s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 4px 20px rgba(232, 213, 181, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--gold-hover) 0%, var(--gold) 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 28px rgba(232, 213, 181, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-message {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(232, 213, 181, 0.08);
|
||||||
|
border: 1px solid rgba(232, 213, 181, 0.22);
|
||||||
|
color: var(--gold-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-divider {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1.35rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-divider span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(15, 22, 38, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-sso-login {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-sso-login:hover {
|
||||||
|
background: rgba(232, 213, 181, 0.06);
|
||||||
|
border-color: rgba(232, 213, 181, 0.35);
|
||||||
|
color: var(--gold-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="brand-block">
|
||||||
|
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV — Plataforma IPTV" width="320" height="320" decoding="async" class="login-brand-img">
|
||||||
|
<p class="brand-tagline">Plataforma IPTV</p>
|
||||||
|
<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">Usuario</label>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username"
|
||||||
|
placeholder="Tu nombre de usuario">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Contraseña</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||||
|
minlength="6" placeholder="Tu contraseña">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-login" id="submit-btn">Entrar</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="sso-divider">
|
||||||
|
<span>o</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="btn-sso-login">
|
||||||
|
<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>
|
||||||
|
Entrar con SSO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
||||||
|
window.location.href = '/api/auth/oidc/login';
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 'Crea la cuenta de administrador';
|
||||||
|
document.getElementById('submit-btn').textContent = 'Crear cuenta';
|
||||||
|
document.getElementById('setup-message').classList.add('show');
|
||||||
|
document.getElementById('password').placeholder = 'Mínimo 6 caracteres';
|
||||||
|
document.querySelector('.sso-divider').style.display = 'none';
|
||||||
|
document.getElementById('btn-sso-login').style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking setup status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
errorMessage.classList.remove('show');
|
||||||
|
errorMessage.textContent = '';
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Espera…';
|
||||||
|
|
||||||
|
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 || 'Error de autenticación');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('authToken', data.token);
|
||||||
|
window.location.replace('/');
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.textContent = err.message;
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = isSetupMode ? 'Crear cuenta' : 'Entrar';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
checkSetupRequired();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const passport = require('passport');
|
||||||
|
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||||
|
const { Strategy: LocalStrategy } = require('passport-local');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication and Authorization Module
|
||||||
|
* Handles user authentication, session management, and role-based access control
|
||||||
|
* Using Passport.js with JWT tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
// JWT Secret - In production, use environment variable
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
|
||||||
|
const JWT_EXPIRY = '24h';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash password using bcrypt
|
||||||
|
*/
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
return bcrypt.hash(password, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify password against hash
|
||||||
|
*/
|
||||||
|
async function verifyPassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate JWT token
|
||||||
|
*/
|
||||||
|
function generateToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: JWT_EXPIRY }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify JWT token
|
||||||
|
*/
|
||||||
|
function verifyToken(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Passport Local Strategy for username/password authentication
|
||||||
|
*/
|
||||||
|
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
||||||
|
passport.use(new LocalStrategy(
|
||||||
|
async (username, password, done) => {
|
||||||
|
try {
|
||||||
|
const user = await getUserByUsername(username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return done(null, false, { message: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyUserPassword(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return done(null, false, { message: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Passport JWT Strategy for token-based authentication
|
||||||
|
*/
|
||||||
|
function configureJwtStrategy(getUserById) {
|
||||||
|
const options = {
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
secretOrKey: JWT_SECRET
|
||||||
|
};
|
||||||
|
|
||||||
|
passport.use(new JwtStrategy(options, async (payload, done) => {
|
||||||
|
try {
|
||||||
|
const user = await getUserById(payload.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return done(null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, {
|
||||||
|
id: Number(user.id),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return done(err, false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Passport session serialization
|
||||||
|
* Required for OIDC flow which uses sessions
|
||||||
|
*/
|
||||||
|
function configureSessionSerialization(getUserById) {
|
||||||
|
passport.serializeUser((user, done) => {
|
||||||
|
done(null, user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.deserializeUser(async (id, done) => {
|
||||||
|
try {
|
||||||
|
const user = await getUserById(id);
|
||||||
|
done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
done(err, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Passport OpenID Connect Strategy
|
||||||
|
*/
|
||||||
|
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
||||||
|
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
||||||
|
console.warn('OIDC configuration missing - SSO disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
||||||
|
|
||||||
|
passport.use(new OpenIDConnectStrategy({
|
||||||
|
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
|
||||||
|
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
|
||||||
|
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
|
||||||
|
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
|
||||||
|
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
|
||||||
|
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
||||||
|
scope: ['openid', 'profile', 'email']
|
||||||
|
},
|
||||||
|
async (...args) => {
|
||||||
|
// The done callback is always the last argument
|
||||||
|
const done = args[args.length - 1];
|
||||||
|
|
||||||
|
// Map known arguments
|
||||||
|
// Standard: issuer, sub, profile, accessToken, refreshToken, done
|
||||||
|
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
|
||||||
|
|
||||||
|
let issuer, sub, profile;
|
||||||
|
|
||||||
|
if (args.length === 3) {
|
||||||
|
// Scenario: (issuer, profile, done)
|
||||||
|
const arg0 = args[0];
|
||||||
|
const arg1 = args[1];
|
||||||
|
|
||||||
|
if (typeof arg1 === 'object' && arg1.id) {
|
||||||
|
issuer = arg0;
|
||||||
|
profile = arg1;
|
||||||
|
sub = profile.id;
|
||||||
|
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
|
||||||
|
issuer = arg0;
|
||||||
|
sub = arg1;
|
||||||
|
profile = { id: sub, displayName: 'Unknown' };
|
||||||
|
}
|
||||||
|
} else if (args.length >= 4) {
|
||||||
|
// Assume standard: iss, sub, profile...
|
||||||
|
issuer = args[0];
|
||||||
|
sub = args[1];
|
||||||
|
profile = args[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sub && profile && profile.id) sub = profile.id;
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try to find by OIDC ID (sub)
|
||||||
|
let user = await findUserByOidcId(sub);
|
||||||
|
|
||||||
|
// 2. If not found, try to match by email
|
||||||
|
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
|
||||||
|
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
|
||||||
|
|
||||||
|
if (!user && email) {
|
||||||
|
user = await findUserByEmail(email);
|
||||||
|
|
||||||
|
// If found by email but no OIDC ID, link them
|
||||||
|
if (user && !user.oidcId) {
|
||||||
|
// We don't have a direct update method for specific fields without full user object in this context
|
||||||
|
// Ideally we'd update the user here. For now, we'll just log in.
|
||||||
|
// Future: Update user with oidcId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If still not found, create new user (JIT Provisioning)
|
||||||
|
if (!user) {
|
||||||
|
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
|
||||||
|
|
||||||
|
user = await createUser({
|
||||||
|
username: username,
|
||||||
|
role: 'viewer', // Default role for SSO users
|
||||||
|
oidcId: sub,
|
||||||
|
email: email || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Require authentication using Passport JWT
|
||||||
|
*/
|
||||||
|
const requireAuth = passport.authenticate('jwt', { session: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if user has admin role (case-insensitive, tolerates stray whitespace).
|
||||||
|
*/
|
||||||
|
function isAdminRole(user) {
|
||||||
|
return !!(user && String(user.role || '').trim().toLowerCase() === 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Require admin role
|
||||||
|
*/
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!isAdminRole(req.user)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Check for specific role
|
||||||
|
*/
|
||||||
|
function requireRole(role) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user || req.user.role !== role) {
|
||||||
|
return res.status(403).json({ error: `Forbidden - ${role} access required` });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
passport,
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
generateToken,
|
||||||
|
verifyToken,
|
||||||
|
configureLocalStrategy,
|
||||||
|
configureJwtStrategy,
|
||||||
|
configureSessionSerialization,
|
||||||
|
configureOidcStrategy,
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
requireRole,
|
||||||
|
isAdminRole
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const { existsSync, mkdirSync } = require('fs');
|
||||||
|
|
||||||
|
// Ensure data directory exists (sync is fine for startup)
|
||||||
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(dataDir, 'db.json');
|
||||||
|
|
||||||
|
// Initialize database structure
|
||||||
|
async function loadDb() {
|
||||||
|
try {
|
||||||
|
// Check if file exists (using fs.access is better for async, but we can catch ENOENT)
|
||||||
|
try {
|
||||||
|
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
return {
|
||||||
|
sources: data.sources || [],
|
||||||
|
hiddenItems: data.hiddenItems || [],
|
||||||
|
favorites: data.favorites || [],
|
||||||
|
settings: data.settings || getDefaultSettings(),
|
||||||
|
users: data.users || [],
|
||||||
|
nextId: data.nextId || 1
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
// File doesn't exist, return default
|
||||||
|
return {
|
||||||
|
sources: [],
|
||||||
|
hiddenItems: [],
|
||||||
|
favorites: [],
|
||||||
|
settings: getDefaultSettings(),
|
||||||
|
users: [],
|
||||||
|
nextId: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading database:', err);
|
||||||
|
// Return safe default on error to prevent crashing, but log it
|
||||||
|
return {
|
||||||
|
sources: [],
|
||||||
|
hiddenItems: [],
|
||||||
|
favorites: [],
|
||||||
|
settings: getDefaultSettings(),
|
||||||
|
users: [],
|
||||||
|
nextId: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
function getDefaultSettings() {
|
||||||
|
return {
|
||||||
|
arrowKeysChangeChannel: true,
|
||||||
|
overlayDuration: 5,
|
||||||
|
defaultVolume: 80,
|
||||||
|
rememberVolume: true,
|
||||||
|
lastVolume: 80,
|
||||||
|
autoPlayNextEpisode: false,
|
||||||
|
forceProxy: false,
|
||||||
|
forceTranscode: false, // Force Audio Transcode
|
||||||
|
forceVideoTranscode: false, // Force Video Transcode
|
||||||
|
forceRemux: false,
|
||||||
|
autoTranscode: true,
|
||||||
|
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts.
|
||||||
|
streamFormat: 'ts',
|
||||||
|
epgRefreshInterval: '24',
|
||||||
|
// User-Agent settings
|
||||||
|
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
|
||||||
|
userAgentCustom: '', // Custom UA string when preset is 'custom'
|
||||||
|
// Transcoding settings
|
||||||
|
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
|
||||||
|
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
|
||||||
|
quality: 'medium', // high | medium | low
|
||||||
|
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
|
||||||
|
// Probe cache settings
|
||||||
|
probeCacheTTL: 300, // 5 minutes for URL probe cache
|
||||||
|
seriesProbeCacheDays: 7, // 7 days for series episode probe cache
|
||||||
|
// Upscaling settings
|
||||||
|
upscaleEnabled: false,
|
||||||
|
upscaleMethod: 'hardware', // hardware | software
|
||||||
|
upscaleTarget: '1080p' // 1080p | 4k | 720p
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Agent presets
|
||||||
|
const USER_AGENT_PRESETS = {
|
||||||
|
chrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
|
vlc: 'VLC/3.0.20 LibVLC/3.0.20',
|
||||||
|
tivimate: 'TiviMate/4.7.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUserAgent(settings) {
|
||||||
|
if (settings.userAgentPreset === 'custom' && settings.userAgentCustom) {
|
||||||
|
return settings.userAgentCustom;
|
||||||
|
}
|
||||||
|
return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write lock to prevent concurrent writes from corrupting db.json
|
||||||
|
let writeQueue = Promise.resolve();
|
||||||
|
const tmpPath = dbPath + '.tmp';
|
||||||
|
|
||||||
|
async function saveDb(data) {
|
||||||
|
// Queue this write operation - each write waits for the previous one
|
||||||
|
writeQueue = writeQueue.then(async () => {
|
||||||
|
try {
|
||||||
|
const jsonString = JSON.stringify(data, null, 2);
|
||||||
|
// Atomic write: write to temp file, then rename
|
||||||
|
// Rename is atomic on most filesystems, preventing corruption on crash
|
||||||
|
await fs.writeFile(tmpPath, jsonString);
|
||||||
|
await fs.rename(tmpPath, dbPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error writing database:', err);
|
||||||
|
// Clean up temp file if it exists
|
||||||
|
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Database write failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return writeQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source CRUD operations
|
||||||
|
const sources = {
|
||||||
|
async getAll() {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.sources;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.sources.find(s => s.id === parseInt(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByType(type) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.sources.filter(s => s.type === type && s.enabled);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(source) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const newSource = {
|
||||||
|
id: db.nextId++,
|
||||||
|
...source,
|
||||||
|
enabled: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
db.sources.push(newSource);
|
||||||
|
await saveDb(db);
|
||||||
|
return newSource;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, updates) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
db.sources[index] = {
|
||||||
|
...db.sources[index],
|
||||||
|
...updates,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
await saveDb(db);
|
||||||
|
return db.sources[index];
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
const db = await loadDb();
|
||||||
|
db.sources = db.sources.filter(s => s.id !== parseInt(id));
|
||||||
|
// Also delete related hidden items and favorites
|
||||||
|
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
||||||
|
db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id));
|
||||||
|
await saveDb(db);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleEnabled(id) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const source = db.sources.find(s => s.id === parseInt(id));
|
||||||
|
if (source) {
|
||||||
|
source.enabled = !source.enabled;
|
||||||
|
source.updated_at = new Date().toISOString();
|
||||||
|
await saveDb(db);
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hidden items operations
|
||||||
|
const hiddenItems = {
|
||||||
|
async getAll(sourceId = null) {
|
||||||
|
const db = await loadDb();
|
||||||
|
if (sourceId) {
|
||||||
|
return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId));
|
||||||
|
}
|
||||||
|
return db.hiddenItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
async hide(sourceId, itemType, itemId) {
|
||||||
|
const db = await loadDb();
|
||||||
|
// Check if already hidden
|
||||||
|
const exists = db.hiddenItems.find(
|
||||||
|
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
db.hiddenItems.push({
|
||||||
|
id: db.nextId++,
|
||||||
|
source_id: parseInt(sourceId),
|
||||||
|
item_type: itemType,
|
||||||
|
item_id: itemId
|
||||||
|
});
|
||||||
|
await saveDb(db);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async show(sourceId, itemType, itemId) {
|
||||||
|
const db = await loadDb();
|
||||||
|
db.hiddenItems = db.hiddenItems.filter(
|
||||||
|
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
|
||||||
|
);
|
||||||
|
await saveDb(db);
|
||||||
|
},
|
||||||
|
|
||||||
|
async isHidden(sourceId, itemType, itemId) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.hiddenItems.some(
|
||||||
|
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkHide(items) {
|
||||||
|
const db = await loadDb();
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const { sourceId, itemType, itemId } = item;
|
||||||
|
const exists = db.hiddenItems.find(
|
||||||
|
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
db.hiddenItems.push({
|
||||||
|
id: db.nextId++,
|
||||||
|
source_id: parseInt(sourceId),
|
||||||
|
item_type: itemType,
|
||||||
|
item_id: itemId
|
||||||
|
});
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
await saveDb(db);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkShow(items) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const initialLength = db.hiddenItems.length;
|
||||||
|
|
||||||
|
// Create a set of "signatures" for O(1) lookup of items to remove
|
||||||
|
const toRemove = new Set(items.map(i => `${i.sourceId}:${i.itemType}:${i.itemId}`));
|
||||||
|
|
||||||
|
db.hiddenItems = db.hiddenItems.filter(h =>
|
||||||
|
!toRemove.has(`${h.source_id}:${h.item_type}:${h.item_id}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (db.hiddenItems.length !== initialLength) {
|
||||||
|
await saveDb(db);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Favorites operations
|
||||||
|
const favorites = {
|
||||||
|
async getAll(sourceId = null, itemType = null) {
|
||||||
|
const db = await loadDb();
|
||||||
|
let results = db.favorites;
|
||||||
|
if (sourceId) {
|
||||||
|
results = results.filter(f => f.source_id === parseInt(sourceId));
|
||||||
|
}
|
||||||
|
if (itemType) {
|
||||||
|
results = results.filter(f => f.item_type === itemType);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(sourceId, itemId, itemType = 'channel') {
|
||||||
|
const db = await loadDb();
|
||||||
|
// Check if already favorited
|
||||||
|
const exists = db.favorites.find(
|
||||||
|
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
db.favorites.push({
|
||||||
|
id: db.nextId++,
|
||||||
|
source_id: parseInt(sourceId),
|
||||||
|
item_id: String(itemId),
|
||||||
|
item_type: itemType, // 'channel', 'movie', 'series'
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
await saveDb(db);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(sourceId, itemId, itemType = 'channel') {
|
||||||
|
const db = await loadDb();
|
||||||
|
db.favorites = db.favorites.filter(
|
||||||
|
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
|
||||||
|
);
|
||||||
|
await saveDb(db);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async isFavorite(sourceId, itemId, itemType = 'channel') {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.favorites.some(
|
||||||
|
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings operations
|
||||||
|
const settings = {
|
||||||
|
async get() {
|
||||||
|
const db = await loadDb();
|
||||||
|
return { ...getDefaultSettings(), ...db.settings };
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(newSettings) {
|
||||||
|
const db = await loadDb();
|
||||||
|
db.settings = { ...db.settings, ...newSettings };
|
||||||
|
await saveDb(db);
|
||||||
|
return db.settings;
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
const db = await loadDb();
|
||||||
|
db.settings = getDefaultSettings();
|
||||||
|
await saveDb(db);
|
||||||
|
return db.settings;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
const users = {
|
||||||
|
async getAll() {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.id === parseInt(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByUsername(username) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.username === username);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByOidcId(oidcId) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.oidcId === oidcId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByEmail(email) {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.find(u => u.email === email);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(userData) {
|
||||||
|
const db = await loadDb();
|
||||||
|
if (!db.users) {
|
||||||
|
db.users = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
if (db.users.some(u => u.username === userData.username)) {
|
||||||
|
throw new Error('Username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: db.nextId++,
|
||||||
|
username: userData.username,
|
||||||
|
// For OIDC users, passwordHash is optional
|
||||||
|
passwordHash: userData.passwordHash || null,
|
||||||
|
role: userData.role || 'viewer',
|
||||||
|
oidcId: userData.oidcId || null,
|
||||||
|
email: userData.email || null,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
db.users.push(newUser);
|
||||||
|
await saveDb(db);
|
||||||
|
|
||||||
|
// Return user without password hash
|
||||||
|
const { passwordHash, ...userWithoutPassword } = newUser;
|
||||||
|
return userWithoutPassword;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, updates) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
||||||
|
|
||||||
|
if (userIndex === -1 || userIndex === undefined) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is being changed and if it already exists
|
||||||
|
if (updates.username && updates.username !== db.users[userIndex].username) {
|
||||||
|
if (db.users.some(u => u.username === updates.username)) {
|
||||||
|
throw new Error('Username already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.users[userIndex] = {
|
||||||
|
...db.users[userIndex],
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveDb(db);
|
||||||
|
|
||||||
|
// Return user without password hash
|
||||||
|
const { passwordHash, ...userWithoutPassword } = db.users[userIndex];
|
||||||
|
return userWithoutPassword;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
const db = await loadDb();
|
||||||
|
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
||||||
|
|
||||||
|
if (userIndex === -1 || userIndex === undefined) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the last admin
|
||||||
|
const user = db.users[userIndex];
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const adminCount = db.users.filter(u => u.role === 'admin').length;
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new Error('Cannot delete the last admin user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.users.splice(userIndex, 1);
|
||||||
|
await saveDb(db);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async count() {
|
||||||
|
const db = await loadDb();
|
||||||
|
return db.users?.length || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../db/sqlite');
|
||||||
|
const { sources } = require('../db');
|
||||||
|
const { requireAuth } = require('../auth');
|
||||||
|
const {
|
||||||
|
getAccessibleSourceIds,
|
||||||
|
assertCanUseSourceById
|
||||||
|
} = require('../sourceAccess');
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// Helper to map API item types to DB types and tables
|
||||||
|
function mapItemType(apiType) {
|
||||||
|
switch (apiType) {
|
||||||
|
case 'channel': return { table: 'playlist_items', type: 'live' };
|
||||||
|
case 'group': return { table: 'categories', type: 'live' };
|
||||||
|
case 'vod_category': return { table: 'categories', type: 'movie' };
|
||||||
|
case 'series_category': return { table: 'categories', type: 'series' };
|
||||||
|
case 'movie': return { table: 'playlist_items', type: 'movie' };
|
||||||
|
case 'series': return { table: 'playlist_items', type: 'series' };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all hidden items (formatted like db.json for frontend compatibility)
|
||||||
|
router.get('/hidden', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId } = req.query;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
let allowedIds = null;
|
||||||
|
if (req.user.role !== 'admin') {
|
||||||
|
allowedIds = await getAccessibleSourceIds(req, sources);
|
||||||
|
if (allowedIds.length === 0) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultFormat = (row, itemType) => ({
|
||||||
|
source_id: row.source_id,
|
||||||
|
item_type: itemType,
|
||||||
|
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
|
||||||
|
});
|
||||||
|
|
||||||
|
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
|
||||||
|
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
|
||||||
|
let catParams = [];
|
||||||
|
let itemParams = [];
|
||||||
|
|
||||||
|
if (sourceId) {
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
const sid = parseInt(sourceId, 10);
|
||||||
|
catQuery += ` AND source_id = ?`;
|
||||||
|
itemQuery += ` AND source_id = ?`;
|
||||||
|
catParams.push(sid);
|
||||||
|
itemParams.push(sid);
|
||||||
|
} else if (allowedIds) {
|
||||||
|
const ph = allowedIds.map(() => '?').join(',');
|
||||||
|
catQuery += ` AND source_id IN (${ph})`;
|
||||||
|
itemQuery += ` AND source_id IN (${ph})`;
|
||||||
|
catParams = [...allowedIds];
|
||||||
|
itemParams = [...allowedIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenCats = db.prepare(catQuery).all(...catParams);
|
||||||
|
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
|
||||||
|
|
||||||
|
const hidden = [];
|
||||||
|
|
||||||
|
hiddenCats.forEach(row => {
|
||||||
|
let apiType;
|
||||||
|
if (row.type === 'live') apiType = 'group';
|
||||||
|
else if (row.type === 'movie') apiType = 'vod_category';
|
||||||
|
else if (row.type === 'series') apiType = 'series_category';
|
||||||
|
|
||||||
|
if (apiType) hidden.push(resultFormat(row, apiType));
|
||||||
|
});
|
||||||
|
|
||||||
|
hiddenItems.forEach(row => {
|
||||||
|
let apiType;
|
||||||
|
if (row.type === 'live') apiType = 'channel';
|
||||||
|
else if (row.type === 'movie') apiType = 'movie';
|
||||||
|
else if (row.type === 'series') apiType = 'series';
|
||||||
|
|
||||||
|
if (apiType) hidden.push(resultFormat(row, apiType));
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(hidden);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting hidden items:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get hidden items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide item
|
||||||
|
router.post('/hide', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId, itemType, itemId } = req.body;
|
||||||
|
const mapping = mapItemType(itemType);
|
||||||
|
|
||||||
|
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE ${mapping.table}
|
||||||
|
SET is_hidden = 1
|
||||||
|
WHERE source_id = ? AND type = ? AND ${idCol} = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(sourceId, mapping.type, itemId);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error hiding item:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to hide item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show item
|
||||||
|
router.post('/show', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId, itemType, itemId } = req.body;
|
||||||
|
const mapping = mapItemType(itemType);
|
||||||
|
|
||||||
|
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE ${mapping.table}
|
||||||
|
SET is_hidden = 0
|
||||||
|
WHERE source_id = ? AND type = ? AND ${idCol} = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(sourceId, mapping.type, itemId);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error showing item:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to show item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check hidden status
|
||||||
|
router.get('/hidden/check', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId, itemType, itemId } = req.query;
|
||||||
|
const mapping = mapItemType(itemType);
|
||||||
|
if (!mapping) return res.json({ hidden: false });
|
||||||
|
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT is_hidden FROM ${mapping.table}
|
||||||
|
WHERE source_id = ? AND type = ? AND ${idCol} = ?
|
||||||
|
`).get(sourceId, mapping.type, itemId);
|
||||||
|
|
||||||
|
res.json({ hidden: !!(row && row.is_hidden) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking hidden:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to check status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Hide
|
||||||
|
router.post('/hide/bulk', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items } = req.body;
|
||||||
|
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||||
|
for (const sid of uniqueIds) {
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||||
|
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||||
|
|
||||||
|
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||||
|
|
||||||
|
const runBulk = db.transaction((list) => {
|
||||||
|
for (const item of list) {
|
||||||
|
const mapping = mapItemType(item.itemType);
|
||||||
|
if (mapping) {
|
||||||
|
if (mapping.table === 'categories') {
|
||||||
|
hideCat.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
} else {
|
||||||
|
hideItem.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runBulk(items);
|
||||||
|
res.json({ success: true, count: items.length });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'SQLITE_BUSY') {
|
||||||
|
return res.status(503).json({ error: 'Database is busy, please try again' });
|
||||||
|
}
|
||||||
|
console.error('Error bulk hide:', err);
|
||||||
|
res.status(500).json({ error: 'Failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Show
|
||||||
|
router.post('/show/bulk', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items } = req.body;
|
||||||
|
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||||
|
for (const sid of uniqueIds) {
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||||
|
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||||
|
|
||||||
|
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||||
|
|
||||||
|
const runBulk = db.transaction((list) => {
|
||||||
|
for (const item of list) {
|
||||||
|
const mapping = mapItemType(item.itemType);
|
||||||
|
if (mapping) {
|
||||||
|
if (mapping.table === 'categories') {
|
||||||
|
showCat.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
} else {
|
||||||
|
showItem.run(item.sourceId, mapping.type, item.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runBulk(items);
|
||||||
|
res.json({ success: true, count: items.length });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'SQLITE_BUSY') {
|
||||||
|
return res.status(503).json({ error: 'Database is busy, please try again' });
|
||||||
|
}
|
||||||
|
console.error('Error bulk show:', err);
|
||||||
|
res.status(500).json({ error: 'Failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show ALL items for a source (single SQL statement - much faster than bulk)
|
||||||
|
router.post('/show/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId, contentType } = req.body;
|
||||||
|
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||||
|
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
let catCount = 0;
|
||||||
|
let itemCount = 0;
|
||||||
|
|
||||||
|
const types = contentType === 'movies' ? ['movie']
|
||||||
|
: contentType === 'series' ? ['series']
|
||||||
|
: ['live'];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||||
|
const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||||
|
catCount += catResult.changes;
|
||||||
|
itemCount += itemResult.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Channels] Show all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`);
|
||||||
|
res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error show all:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to show all' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide ALL items for a source (single SQL statement - much faster than bulk)
|
||||||
|
router.post('/hide/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceId, contentType } = req.body;
|
||||||
|
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||||
|
|
||||||
|
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
let catCount = 0;
|
||||||
|
let itemCount = 0;
|
||||||
|
|
||||||
|
const types = contentType === 'movies' ? ['movie']
|
||||||
|
: contentType === 'series' ? ['series']
|
||||||
|
: ['live'];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||||
|
const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||||
|
catCount += catResult.changes;
|
||||||
|
itemCount += itemResult.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Channels] Hide all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`);
|
||||||
|
res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error hide all:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to hide all' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get recent movies or series
|
||||||
|
router.get('/recent', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, limit = 12 } = req.query;
|
||||||
|
if (!type || (type !== 'movie' && type !== 'series')) {
|
||||||
|
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessibleFilter = '';
|
||||||
|
const params = [type];
|
||||||
|
if (req.user.role !== 'admin') {
|
||||||
|
const ids = await getAccessibleSourceIds(req, sources);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
|
||||||
|
params.push(...ids);
|
||||||
|
}
|
||||||
|
params.push(parseInt(limit, 10));
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const recentItems = db.prepare(`
|
||||||
|
SELECT * FROM playlist_items p
|
||||||
|
WHERE p.type = ?
|
||||||
|
AND p.is_hidden = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM categories c
|
||||||
|
WHERE c.source_id = p.source_id
|
||||||
|
AND c.category_id = p.category_id
|
||||||
|
AND c.type = p.type
|
||||||
|
AND c.is_hidden = 1
|
||||||
|
)
|
||||||
|
${accessibleFilter}
|
||||||
|
ORDER BY p.added_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...params);
|
||||||
|
|
||||||
|
const formatted = recentItems.map(item => ({
|
||||||
|
...item,
|
||||||
|
data: JSON.parse(item.data)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(formatted);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error getting recent ${req.query.type}:`, err);
|
||||||
|
res.status(500).json({ error: 'Failed to get recent items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe endpoint - detects stream codecs and container
|
||||||
|
* GET /api/probe?url=...
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* {
|
||||||
|
* video: "h264",
|
||||||
|
* audio: "aac",
|
||||||
|
* container: "mpegts",
|
||||||
|
* compatible: true,
|
||||||
|
* needsRemux: false,
|
||||||
|
* needsTranscode: false
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Probe cache (URL → result)
|
||||||
|
const probeCache = new Map();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Browser-compatible codecs
|
||||||
|
const BROWSER_VIDEO_CODECS = ['h264', 'avc', 'avc1'];
|
||||||
|
const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe stream with ffprobe
|
||||||
|
*/
|
||||||
|
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-v', 'error',
|
||||||
|
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
|
'-print_format', 'json',
|
||||||
|
'-show_streams',
|
||||||
|
'-show_format',
|
||||||
|
'-probesize', '5000000',
|
||||||
|
'-analyzeduration', '5000000',
|
||||||
|
url
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(ffprobePath, args);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
reject(new Error('Probe timeout'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => { stdout += data; });
|
||||||
|
proc.stderr.on('data', (data) => { stderr += data; });
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`ffprobe exited with code ${code}: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
resolve(result);
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('Failed to parse ffprobe output'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze probe result and determine compatibility
|
||||||
|
*/
|
||||||
|
function analyzeProbeResult(probeResult, url) {
|
||||||
|
const streams = probeResult.streams || [];
|
||||||
|
const format = probeResult.format || {};
|
||||||
|
|
||||||
|
const videoStream = streams.find(s => s.codec_type === 'video');
|
||||||
|
const audioStream = streams.find(s => s.codec_type === 'audio');
|
||||||
|
|
||||||
|
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
|
||||||
|
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
|
||||||
|
const container = format.format_name?.toLowerCase() || 'unknown';
|
||||||
|
|
||||||
|
// Check codec compatibility
|
||||||
|
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
|
||||||
|
const audioOk = BROWSER_AUDIO_CODECS.some(c => audioCodec.includes(c));
|
||||||
|
|
||||||
|
// Browser-safe containers
|
||||||
|
// Note: We exclude 'webm' because ffprobe reports MKV as "matroska,webm",
|
||||||
|
// and H.264/AAC in MKV/WebM is not universally supported. Best to remux to MP4.
|
||||||
|
const BROWSER_CONTAINERS = ['hls', 'mp4', 'mov'];
|
||||||
|
const containerOk = BROWSER_CONTAINERS.some(c => container.includes(c));
|
||||||
|
|
||||||
|
// Check if it's a raw TS stream (not HLS)
|
||||||
|
const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8');
|
||||||
|
|
||||||
|
// Extract subtitle tracks
|
||||||
|
const subtitles = streams
|
||||||
|
.filter(s => s.codec_type === 'subtitle' && s.codec_name !== 'timed_id3' && s.codec_name !== 'bin_data')
|
||||||
|
.map(s => ({
|
||||||
|
index: s.index,
|
||||||
|
language: s.tags?.language || 'und',
|
||||||
|
title: s.tags?.title || s.tags?.language || `Track ${s.index}`,
|
||||||
|
codec: s.codec_name
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine what processing is needed
|
||||||
|
// 4. MKV files often cause OOM/decoding issues in browser fMP4 remux,
|
||||||
|
// so we force them to "needsTranscode" which uses HLS (more robust).
|
||||||
|
// The frontend will still use "copy" mode if codecs are compatible.
|
||||||
|
const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv');
|
||||||
|
|
||||||
|
// 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy)
|
||||||
|
const needsTranscode = !audioOk || !videoOk || isMkv;
|
||||||
|
|
||||||
|
// 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe)
|
||||||
|
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
|
||||||
|
|
||||||
|
const compatible = !needsTranscode && !needsRemux;
|
||||||
|
|
||||||
|
return {
|
||||||
|
video: videoCodec,
|
||||||
|
audio: audioCodec,
|
||||||
|
width: videoStream?.width || 0,
|
||||||
|
height: videoStream?.height || 0,
|
||||||
|
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
|
||||||
|
container: container,
|
||||||
|
compatible: compatible,
|
||||||
|
needsRemux: needsRemux,
|
||||||
|
needsTranscode: needsTranscode,
|
||||||
|
subtitles: subtitles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { url, ua } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffprobePath = req.app.locals.ffprobePath;
|
||||||
|
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
|
||||||
|
|
||||||
|
if (!ffprobePath) {
|
||||||
|
// No ffprobe available - assume needs transcoding to be safe
|
||||||
|
console.log('[Probe] FFprobe not available, assuming transcode needed');
|
||||||
|
return res.json({
|
||||||
|
video: 'unknown',
|
||||||
|
audio: 'unknown',
|
||||||
|
container: 'unknown',
|
||||||
|
compatible: false,
|
||||||
|
needsRemux: false,
|
||||||
|
needsTranscode: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = probeCache.get(cacheKey);
|
||||||
|
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||||
|
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
||||||
|
return res.json(cached.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||||
|
|
||||||
|
// Retry probe up to 3 times on 5XX errors (provider slot not released yet)
|
||||||
|
const MAX_PROBE_RETRIES = 3;
|
||||||
|
const PROBE_RETRY_DELAY_MS = 2500;
|
||||||
|
let lastErr = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||||
|
const analysis = analyzeProbeResult(probeResult, url);
|
||||||
|
|
||||||
|
// Cache successful result
|
||||||
|
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||||
|
|
||||||
|
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||||
|
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||||
|
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
||||||
|
|
||||||
|
return res.json(analysis);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
const is5xx = /5[0-9]{2}|Server Error/i.test(err.message);
|
||||||
|
if (is5xx && attempt < MAX_PROBE_RETRIES) {
|
||||||
|
console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[Probe] Failed after retries:', lastErr.message);
|
||||||
|
|
||||||
|
// If there's a stale cached result, use it rather than assuming needsTranscode
|
||||||
|
const stale = probeCache.get(cacheKey);
|
||||||
|
if (stale) {
|
||||||
|
console.warn('[Probe] Using stale cached result as fallback');
|
||||||
|
return res.json({ ...stale.result, stale: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: assume transcode needed
|
||||||
|
res.json({
|
||||||
|
video: 'unknown',
|
||||||
|
audio: 'unknown',
|
||||||
|
container: 'unknown',
|
||||||
|
compatible: false,
|
||||||
|
needsRemux: false,
|
||||||
|
needsTranscode: true,
|
||||||
|
error: lastErr.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,852 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { sources } = require('../db');
|
||||||
|
const { getDb } = require('../db/sqlite'); // Import SQLite
|
||||||
|
const xtreamApi = require('../services/xtreamApi');
|
||||||
|
const epgParser = require('../services/epgParser');
|
||||||
|
const cache = require('../services/cache');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const ffmpegPath = require('ffmpeg-static');
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
const auth = require('../auth');
|
||||||
|
const { canUserAccessSource } = require('../sourceAccess');
|
||||||
|
|
||||||
|
// Las rutas de proxy (categorías, streams, EPG, imágenes, HLS, etc.) son accedidas
|
||||||
|
// directamente por el reproductor y los navegadores sin header Authorization.
|
||||||
|
// El control de acceso a fuentes se hace en /api/sources (CRUD).
|
||||||
|
// Por eso NO aplicamos requireAuth globalmente aquí.
|
||||||
|
|
||||||
|
router.param('sourceId', async (req, res, next, id) => {
|
||||||
|
try {
|
||||||
|
const sid = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(sid)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid source id' });
|
||||||
|
}
|
||||||
|
const source = await sources.getById(sid);
|
||||||
|
if (!source) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
// Solo bloqueamos si hay usuario autenticado y no tiene acceso
|
||||||
|
// (sin token, req.user es undefined → dejamos pasar, el reproductor no lleva Bearer)
|
||||||
|
if (req.user) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.grantedSource = source;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default cache max age in hours
|
||||||
|
const DEFAULT_MAX_AGE_HOURS = 24;
|
||||||
|
|
||||||
|
// Helper to get formatted category list from DB
|
||||||
|
function getCategoriesFromDb(sourceId, type, includeHidden = false) {
|
||||||
|
const db = getDb();
|
||||||
|
let query = `
|
||||||
|
SELECT category_id, name as category_name, parent_id
|
||||||
|
FROM categories
|
||||||
|
WHERE source_id = ? AND type = ?
|
||||||
|
`;
|
||||||
|
if (!includeHidden) {
|
||||||
|
query += ` AND is_hidden = 0`;
|
||||||
|
}
|
||||||
|
query += ` ORDER BY name ASC`;
|
||||||
|
const cats = db.prepare(query).all(sourceId, type);
|
||||||
|
return cats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get formatted streams from DB
|
||||||
|
function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = false) {
|
||||||
|
const db = getDb();
|
||||||
|
let query = `
|
||||||
|
SELECT item_id, name, stream_icon, added_at, rating, container_extension, year, category_id, data
|
||||||
|
FROM playlist_items
|
||||||
|
WHERE source_id = ? AND type = ?
|
||||||
|
`;
|
||||||
|
if (!includeHidden) {
|
||||||
|
query += ` AND is_hidden = 0`;
|
||||||
|
}
|
||||||
|
const params = [sourceId, type];
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
query += ` AND category_id = ?`;
|
||||||
|
params.push(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default sorting
|
||||||
|
// query += ` ORDER BY name ASC`; // Sorting usually handled by client
|
||||||
|
|
||||||
|
const items = db.prepare(query).all(...params);
|
||||||
|
|
||||||
|
// Map to Xtream format
|
||||||
|
return items.map(item => {
|
||||||
|
const data = JSON.parse(item.data || '{}');
|
||||||
|
// Override with our local fields if needed, or just return the mixed object
|
||||||
|
// We should ensure critical fields are present
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
stream_id: item.item_id, // ensure ID matches what client expects
|
||||||
|
series_id: type === 'series' ? item.item_id : undefined,
|
||||||
|
name: item.name,
|
||||||
|
stream_icon: item.stream_icon,
|
||||||
|
cover: item.stream_icon, // series/vod often use cover
|
||||||
|
added: item.added_at,
|
||||||
|
rating: item.rating,
|
||||||
|
container_extension: item.container_extension,
|
||||||
|
category_id: item.category_id,
|
||||||
|
// Normalize EPG channel ID: Xtream uses epg_channel_id, M3U uses tvgId
|
||||||
|
epg_channel_id: data.epg_channel_id || data.tvgId || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Xtream Codes Proxy API --- //
|
||||||
|
|
||||||
|
// Login / Authenticate
|
||||||
|
router.get('/xtream/:sourceId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'xtream') return res.status(404).send('Source not found');
|
||||||
|
|
||||||
|
// Proxy auth check to upstream to ensure credentials are still valid
|
||||||
|
|
||||||
|
const cached = cache.get('xtream', source.id, 'auth', 300000);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
const data = await api.authenticate();
|
||||||
|
cache.set('xtream', source.id, 'auth', data);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Upstream error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Categories
|
||||||
|
router.get('/xtream/:sourceId/live_categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const cats = getCategoriesFromDb(sourceId, 'live', includeHidden);
|
||||||
|
res.json(cats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Streams
|
||||||
|
router.get('/xtream/:sourceId/live_streams', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const categoryId = req.query.category_id;
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const streams = getStreamsFromDb(sourceId, 'live', categoryId, includeHidden);
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// VOD Categories
|
||||||
|
router.get('/xtream/:sourceId/vod_categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const cats = getCategoriesFromDb(sourceId, 'movie', includeHidden);
|
||||||
|
res.json(cats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// VOD Streams
|
||||||
|
router.get('/xtream/:sourceId/vod_streams', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const categoryId = req.query.category_id;
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const streams = getStreamsFromDb(sourceId, 'movie', categoryId, includeHidden);
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series Categories
|
||||||
|
router.get('/xtream/:sourceId/series_categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const cats = getCategoriesFromDb(sourceId, 'series', includeHidden);
|
||||||
|
res.json(cats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series
|
||||||
|
router.get('/xtream/:sourceId/series', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const categoryId = req.query.category_id;
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
const streams = getStreamsFromDb(sourceId, 'series', categoryId, includeHidden);
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series Info (Episodes)
|
||||||
|
// Proxy series info request
|
||||||
|
router.get('/xtream/:sourceId/series_info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
|
||||||
|
const seriesId = req.query.series_id;
|
||||||
|
if (!seriesId) return res.status(400).send('series_id required');
|
||||||
|
|
||||||
|
const cacheKey = `series_info_${seriesId}`;
|
||||||
|
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
const data = await api.getSeriesInfo(seriesId);
|
||||||
|
cache.set('xtream', source.id, cacheKey, data);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Upstream error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// VOD Info
|
||||||
|
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
|
||||||
|
const vodId = req.query.vod_id;
|
||||||
|
if (!vodId) return res.status(400).send('vod_id required');
|
||||||
|
|
||||||
|
const cacheKey = `vod_info_${vodId}`;
|
||||||
|
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
const data = await api.getVodInfo(vodId);
|
||||||
|
cache.set('xtream', source.id, cacheKey, data);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Upstream error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Stream URL for playback
|
||||||
|
// Returns the direct stream URL for a given stream ID
|
||||||
|
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'xtream') {
|
||||||
|
return res.status(404).json({ error: 'Xtream source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamId = req.params.streamId;
|
||||||
|
const type = req.params.type || 'live';
|
||||||
|
const container = req.query.container || 'ts';
|
||||||
|
|
||||||
|
// Construct the Xtream stream URL
|
||||||
|
// Format: http://server:port/live/username/password/streamId.container (for live)
|
||||||
|
// Format: http://server:port/movie/username/password/streamId.container (for movie)
|
||||||
|
// Format: http://server:port/series/username/password/streamId.container (for series)
|
||||||
|
|
||||||
|
let streamUrl;
|
||||||
|
const baseUrl = source.url.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
|
||||||
|
if (type === 'live') {
|
||||||
|
streamUrl = `${baseUrl}/live/${source.username}/${source.password}/${streamId}.${container}`;
|
||||||
|
} else if (type === 'movie') {
|
||||||
|
streamUrl = `${baseUrl}/movie/${source.username}/${source.password}/${streamId}.${container}`;
|
||||||
|
} else if (type === 'series') {
|
||||||
|
streamUrl = `${baseUrl}/series/${source.username}/${source.password}/${streamId}.${container}`;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Invalid stream type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ url: streamUrl });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting stream URL:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get stream URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Other Proxy Routes --- //
|
||||||
|
|
||||||
|
// M3U Playlist
|
||||||
|
// (For M3U sources, we now have data in DB. We can reconstruct M3U or return JSON)
|
||||||
|
// Frontend ChannelList.js for M3U sources calls `API.proxy.m3u.get(sourceId)`
|
||||||
|
// which points here. It expects { channels, groups }.
|
||||||
|
router.get('/m3u/:sourceId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
|
||||||
|
// Fetch from DB
|
||||||
|
const channels = getStreamsFromDb(sourceId, 'live', null, includeHidden);
|
||||||
|
const groups = getCategoriesFromDb(sourceId, 'live', includeHidden);
|
||||||
|
|
||||||
|
// Format for frontend helper
|
||||||
|
// ChannelList expects:
|
||||||
|
// {
|
||||||
|
// channels: [ { id, name, groupTitle, url, tvgLogo, ... } ],
|
||||||
|
// groups: [ { id, name, channelCount } ]
|
||||||
|
// }
|
||||||
|
// Note: DB `live` items from M3U sync have `category_id` as their group name usually.
|
||||||
|
|
||||||
|
const reformattedChannels = channels.map(c => ({
|
||||||
|
...c,
|
||||||
|
id: c.stream_id,
|
||||||
|
groupTitle: c.category_id || 'Uncategorized',
|
||||||
|
url: c.stream_url || c.url,
|
||||||
|
tvgLogo: c.stream_icon
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reformattedGroups = groups.map(g => ({
|
||||||
|
id: g.category_id,
|
||||||
|
name: g.category_name,
|
||||||
|
channelCount: 0 // Frontend calculates this or we can
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add implicit groups check?
|
||||||
|
// The frontend M3U parser generates groups from the channels if explicit groups missing.
|
||||||
|
// Our SyncService `saveCategories` handles explicit groups.
|
||||||
|
|
||||||
|
res.json({ channels: reformattedChannels, groups: reformattedGroups });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// EPG
|
||||||
|
router.get('/epg/:sourceId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.sourceId);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Time window: 24 hours ago to 24 hours from now
|
||||||
|
// This prevents returning millions of rows and crashing the server/browser
|
||||||
|
const windowStart = Date.now() - (24 * 60 * 60 * 1000); // -24 hours
|
||||||
|
const windowEnd = Date.now() + (24 * 60 * 60 * 1000); // +24 hours
|
||||||
|
|
||||||
|
// Fetch programs within the time window
|
||||||
|
let programsQuery = `
|
||||||
|
SELECT channel_id as channelId, start_time, end_time, title, description, data
|
||||||
|
FROM epg_programs
|
||||||
|
WHERE source_id = ? AND end_time > ? AND start_time < ?
|
||||||
|
`;
|
||||||
|
const params = [sourceId, windowStart, windowEnd];
|
||||||
|
|
||||||
|
const programs = db.prepare(programsQuery).all(...params);
|
||||||
|
|
||||||
|
const formattedPrograms = programs.map(p => ({
|
||||||
|
channelId: p.channelId,
|
||||||
|
start: new Date(p.start_time).toISOString(), // EpgGuide parse this back
|
||||||
|
stop: new Date(p.end_time).toISOString(),
|
||||||
|
title: p.title,
|
||||||
|
description: p.description
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fetch EPG channels from playlist_items (type='epg_channel')
|
||||||
|
|
||||||
|
|
||||||
|
let epgChannels = [];
|
||||||
|
|
||||||
|
// Try getting stored channels first
|
||||||
|
const storedChannels = db.prepare(`
|
||||||
|
SELECT item_id as id, name, stream_icon as icon, data
|
||||||
|
FROM playlist_items
|
||||||
|
WHERE source_id = ? AND type = 'epg_channel'
|
||||||
|
`).all(sourceId);
|
||||||
|
|
||||||
|
if (storedChannels.length > 0) {
|
||||||
|
epgChannels = storedChannels;
|
||||||
|
} else {
|
||||||
|
// Fallback: Build from unique channelIds in programmes (Legacy behavior)
|
||||||
|
const uniqueChannelIds = [...new Set(programs.map(p => p.channelId))];
|
||||||
|
epgChannels = uniqueChannelIds.map(id => ({
|
||||||
|
id: id,
|
||||||
|
name: id // Use channelId as name (fallback)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
channels: epgChannels,
|
||||||
|
programmes: formattedPrograms
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear cache (kept for compatibility)
|
||||||
|
router.delete('/cache/:sourceId', (req, res) => {
|
||||||
|
const sourceId = req.params.sourceId;
|
||||||
|
cache.clearSource(sourceId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy Xtream API calls
|
||||||
|
* GET /api/proxy/xtream/:sourceId/:action
|
||||||
|
*/
|
||||||
|
router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = req.params.sourceId;
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'xtream') {
|
||||||
|
return res.status(404).json({ error: 'Xtream source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action } = req.params;
|
||||||
|
const { category_id, stream_id, vod_id, series_id, limit, refresh, maxAge } = req.query;
|
||||||
|
const forceRefresh = refresh === '1';
|
||||||
|
const maxAgeHours = parseInt(maxAge) || DEFAULT_MAX_AGE_HOURS;
|
||||||
|
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Actions that should be cached
|
||||||
|
const cacheableActions = [
|
||||||
|
'live_categories', 'live_streams',
|
||||||
|
'vod_categories', 'vod_streams',
|
||||||
|
'series_categories', 'series'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build cache key (include category_id if present)
|
||||||
|
const cacheKey = category_id ? `${action}_${category_id}` : action;
|
||||||
|
|
||||||
|
// Check cache for cacheable actions
|
||||||
|
if (!forceRefresh && cacheableActions.includes(action)) {
|
||||||
|
const cached = cache.get('xtream', sourceId, cacheKey, maxAgeMs);
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
let data;
|
||||||
|
switch (action) {
|
||||||
|
case 'auth':
|
||||||
|
data = await api.authenticate();
|
||||||
|
break;
|
||||||
|
case 'live_categories':
|
||||||
|
data = await api.getLiveCategories();
|
||||||
|
break;
|
||||||
|
case 'live_streams':
|
||||||
|
data = await api.getLiveStreams(category_id);
|
||||||
|
break;
|
||||||
|
case 'vod_categories':
|
||||||
|
data = await api.getVodCategories();
|
||||||
|
break;
|
||||||
|
case 'vod_streams':
|
||||||
|
data = await api.getVodStreams(category_id);
|
||||||
|
break;
|
||||||
|
case 'vod_info':
|
||||||
|
data = await api.getVodInfo(vod_id);
|
||||||
|
break;
|
||||||
|
case 'series_categories':
|
||||||
|
data = await api.getSeriesCategories();
|
||||||
|
break;
|
||||||
|
case 'series':
|
||||||
|
data = await api.getSeries(category_id);
|
||||||
|
break;
|
||||||
|
case 'series_info':
|
||||||
|
data = await api.getSeriesInfo(series_id);
|
||||||
|
break;
|
||||||
|
case 'short_epg':
|
||||||
|
data = await api.getShortEpg(stream_id, limit);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ error: 'Unknown action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result for cacheable actions
|
||||||
|
if (cacheableActions.includes(action)) {
|
||||||
|
cache.set('xtream', sourceId, cacheKey, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Xtream proxy error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Xtream stream URL
|
||||||
|
* GET /api/proxy/xtream/:sourceId/stream/:streamId
|
||||||
|
*/
|
||||||
|
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'xtream') {
|
||||||
|
return res.status(404).json({ error: 'Xtream source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
const { streamId, type = 'live' } = req.params;
|
||||||
|
const { container = 'ts' } = req.query;
|
||||||
|
|
||||||
|
const url = api.buildStreamUrl(streamId, type, container);
|
||||||
|
res.json({ url });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Stream URL error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse EPG (with file-based caching)
|
||||||
|
* GET /api/proxy/epg/:sourceId
|
||||||
|
* Query params:
|
||||||
|
* - refresh=1 Force refresh, bypass cache
|
||||||
|
* - maxAge=N Max cache age in hours (default 24)
|
||||||
|
*/
|
||||||
|
router.get('/epg/:sourceId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = req.params.sourceId;
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'epg' && source.type !== 'xtream') {
|
||||||
|
return res.status(404).json({ error: 'Valid EPG source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceRefresh = req.query.refresh === '1';
|
||||||
|
const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS;
|
||||||
|
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Check file cache (unless force refresh)
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = cache.get('epg', sourceId, 'data', maxAgeMs);
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
let url = source.url;
|
||||||
|
if (source.type === 'xtream') {
|
||||||
|
const api = xtreamApi.createFromSource(source);
|
||||||
|
url = api.getXmltvUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await epgParser.fetchAndParse(url);
|
||||||
|
|
||||||
|
// Store in file cache
|
||||||
|
cache.set('epg', sourceId, 'data', data);
|
||||||
|
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EPG proxy error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a source
|
||||||
|
* DELETE /api/proxy/cache/:sourceId
|
||||||
|
*/
|
||||||
|
router.delete('/cache/:sourceId', (req, res) => {
|
||||||
|
const sourceId = req.params.sourceId;
|
||||||
|
cache.clearSource(sourceId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear EPG cache for a source (legacy endpoint, calls clearSource)
|
||||||
|
* DELETE /api/proxy/epg/:sourceId/cache
|
||||||
|
*/
|
||||||
|
router.delete('/epg/:sourceId/cache', (req, res) => {
|
||||||
|
const sourceId = req.params.sourceId;
|
||||||
|
cache.clear('epg', sourceId, 'data');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get EPG for specific channels
|
||||||
|
* POST /api/proxy/epg/:sourceId/channels
|
||||||
|
*/
|
||||||
|
router.post('/epg/:sourceId/channels', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = req.grantedSource;
|
||||||
|
if (source.type !== 'epg') {
|
||||||
|
return res.status(404).json({ error: 'EPG source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { channelIds } = req.body;
|
||||||
|
if (!channelIds || !Array.isArray(channelIds)) {
|
||||||
|
return res.status(400).json({ error: 'channelIds array required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await epgParser.fetchAndParse(source.url);
|
||||||
|
|
||||||
|
// Filter programmes for requested channels
|
||||||
|
const result = {};
|
||||||
|
for (const channelId of channelIds) {
|
||||||
|
result[channelId] = epgParser.getCurrentAndUpcoming(data.programmes, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EPG channels error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy stream for playback
|
||||||
|
* This handles CORS for streams that don't allow cross-origin
|
||||||
|
* Supports HTTP Range requests for video seeking
|
||||||
|
*/
|
||||||
|
router.get('/stream', async (req, res) => {
|
||||||
|
const maxRetries = 2;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
let { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward some headers to be more "transparent" back to the origin
|
||||||
|
// Pluto TV uses multiple domains for content delivery
|
||||||
|
const plutoDomains = ['pluto.tv', 'pluto.io', 'plutotv.net', 'siloh.pluto.tv', 'service-stitcher'];
|
||||||
|
const isPluto = plutoDomains.some(domain => url.includes(domain));
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
// Using https and matching the origin of the request
|
||||||
|
'Origin': isPluto ? 'https://pluto.tv' : new URL(url).origin,
|
||||||
|
'Referer': isPluto ? 'https://pluto.tv/' : new URL(url).origin + '/'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward Range header for video seeking support
|
||||||
|
const rangeHeader = req.get('range');
|
||||||
|
if (rangeHeader) {
|
||||||
|
headers['Range'] = rangeHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
// Retry on 5xx errors (transient upstream issues)
|
||||||
|
if (response.status >= 500 && attempt < maxRetries) {
|
||||||
|
console.log(`[Proxy] Upstream 5xx error (attempt ${attempt}/${maxRetries}), retrying in 500ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`);
|
||||||
|
if (response.status === 403) {
|
||||||
|
const errorBody = await response.text().catch(() => 'N/A');
|
||||||
|
console.error(`403 Response body: ${errorBody.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
// Forward range-related headers for video seeking support
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
const contentRange = response.headers.get('content-range');
|
||||||
|
const acceptRanges = response.headers.get('accept-ranges');
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
res.set('Content-Length', contentLength);
|
||||||
|
}
|
||||||
|
if (contentRange) {
|
||||||
|
res.set('Content-Range', contentRange);
|
||||||
|
}
|
||||||
|
if (acceptRanges) {
|
||||||
|
res.set('Accept-Ranges', acceptRanges);
|
||||||
|
} else if (contentLength && !contentRange) {
|
||||||
|
// If server supports content-length but didn't explicitly state accept-ranges,
|
||||||
|
// we can safely assume it supports byte ranges
|
||||||
|
res.set('Accept-Ranges', 'bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status code (206 for partial content when range request was made)
|
||||||
|
res.status(response.status);
|
||||||
|
|
||||||
|
// Create an async iterator for the response body
|
||||||
|
const iterator = response.body[Symbol.asyncIterator]();
|
||||||
|
const first = await iterator.next();
|
||||||
|
|
||||||
|
if (first.done) {
|
||||||
|
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstChunk = Buffer.from(first.value);
|
||||||
|
|
||||||
|
// Peek at first bytes to check for HLS manifest ({ #EXTM3U })
|
||||||
|
const textPrefix = firstChunk.subarray(0, 7).toString('utf8');
|
||||||
|
const contentLooksLikeHls = textPrefix === '#EXTM3U';
|
||||||
|
|
||||||
|
if (contentLooksLikeHls) {
|
||||||
|
// HLS Manifest: We must read the WHOLE manifest to rewrite it
|
||||||
|
const chunks = [firstChunk];
|
||||||
|
|
||||||
|
// Consume the rest of the stream
|
||||||
|
let result = await iterator.next();
|
||||||
|
while (!result.done) {
|
||||||
|
chunks.push(Buffer.from(result.value));
|
||||||
|
result = await iterator.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const finalUrl = response.url || url;
|
||||||
|
console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`);
|
||||||
|
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
|
|
||||||
|
let manifest = buffer.toString('utf-8');
|
||||||
|
|
||||||
|
const finalUrlObj = new URL(finalUrl);
|
||||||
|
const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
manifest = manifest.split('\n').map(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||||
|
// Handle both URI="..." and URI='...' formats
|
||||||
|
if (trimmed.includes('URI=')) {
|
||||||
|
// Replace both double and single quoted URIs
|
||||||
|
return line.replace(/URI=["']([^"']+)["']/g, (match, p1) => {
|
||||||
|
try {
|
||||||
|
const absoluteUrl = new URL(p1, baseUrl).href;
|
||||||
|
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
|
||||||
|
} catch (e) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream URL handling
|
||||||
|
try {
|
||||||
|
let absoluteUrl;
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
absoluteUrl = trimmed;
|
||||||
|
} else {
|
||||||
|
absoluteUrl = new URL(trimmed, baseUrl).href;
|
||||||
|
}
|
||||||
|
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
|
||||||
|
} catch (e) { return line; }
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return res.send(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary content (Video Segment or Key): Collect and send
|
||||||
|
console.log(`[Proxy] Serving binary content (${contentType})`);
|
||||||
|
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||||
|
|
||||||
|
// For small files (like encryption keys), collect all data and send at once
|
||||||
|
// This ensures proper Content-Length and response completion
|
||||||
|
const chunks = [firstChunk];
|
||||||
|
let result = await iterator.next();
|
||||||
|
while (!result.done) {
|
||||||
|
chunks.push(Buffer.from(result.value));
|
||||||
|
result = await iterator.next();
|
||||||
|
}
|
||||||
|
const fullContent = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Set Content-Length for proper client handling
|
||||||
|
res.set('Content-Length', fullContent.length);
|
||||||
|
res.send(fullContent);
|
||||||
|
return; // Success - exit the retry loop
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
console.error(`Stream proxy error (attempt ${attempt}/${maxRetries}):`, err.message);
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
console.log('[Proxy] Retrying after error...');
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: lastError?.message || 'Stream proxy failed after retries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy images (channel logos, posters)
|
||||||
|
* Fixes mixed content errors when loading HTTP images on HTTPS pages
|
||||||
|
* GET /api/proxy/image?url=...
|
||||||
|
*/
|
||||||
|
router.get('/image', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'image/*,*/*;q=0.8'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(response.status).send('Failed to fetch image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/png';
|
||||||
|
res.set('Content-Type', contentType);
|
||||||
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||||
|
|
||||||
|
// Efficiently pipe the response body
|
||||||
|
if (response.body) {
|
||||||
|
// response.body is an AsyncIterable in standard fetch/undici
|
||||||
|
// Readable.from converts it to a Node.js Readable stream
|
||||||
|
const stream = Readable.from(response.body);
|
||||||
|
stream.pipe(res);
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Image proxy error:', err.message);
|
||||||
|
res.status(500).send('Image proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { sources } = require('../db');
|
||||||
|
const { getDb } = require('../db/sqlite');
|
||||||
|
const xtreamApi = require('../services/xtreamApi');
|
||||||
|
const syncService = require('../services/syncService');
|
||||||
|
const m3uParser = require('../services/m3uParser');
|
||||||
|
const { requireAuth, requireAdmin, isAdminRole } = require('../auth');
|
||||||
|
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
function sanitizeSourceList(list) {
|
||||||
|
return list.map((s) => ({
|
||||||
|
...s,
|
||||||
|
password: s.password ? '••••••••' : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOwnerId(body) {
|
||||||
|
const raw = body.ownerId;
|
||||||
|
if (raw === undefined || raw === null || raw === '') return null;
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate by URL (must stay before /:id routes)
|
||||||
|
const M3U_LARGE_THRESHOLD = 50000;
|
||||||
|
|
||||||
|
router.post('/estimate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url, type } = req.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'm3u') {
|
||||||
|
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await m3uParser.countEntries(url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count,
|
||||||
|
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||||
|
threshold: M3U_LARGE_THRESHOLD
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error estimating M3U size:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global sync — admin only
|
||||||
|
router.post('/sync-all', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
syncService.syncAll().catch(console.error);
|
||||||
|
res.json({ success: true, message: 'Global sync started' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting global sync:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to start global sync' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all sources (filtered by access)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allSources = await sources.getAll();
|
||||||
|
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
|
||||||
|
res.json(sanitizeSourceList(filtered));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting sources:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get sources' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sync status (filtered for non-admins)
|
||||||
|
router.get('/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
let statuses = db.prepare('SELECT * FROM sync_status').all();
|
||||||
|
if (req.user.role !== 'admin') {
|
||||||
|
const allowed = new Set(await getAccessibleSourceIds(req, sources));
|
||||||
|
statuses = statuses.filter((row) => allowed.has(row.source_id));
|
||||||
|
}
|
||||||
|
res.json(statuses);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting sync status:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get sync status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sources by type
|
||||||
|
router.get('/type/:type', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const typeSources = await sources.getByType(req.params.type);
|
||||||
|
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s));
|
||||||
|
res.json(sanitizeSourceList(filtered));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting sources by type:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get sources' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estimate by source ID (before bare GET /:id — same base path length)
|
||||||
|
router.get('/:id/estimate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = await sources.getById(req.params.id);
|
||||||
|
if (!source) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type !== 'm3u') {
|
||||||
|
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await m3uParser.countEntries(source.url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count,
|
||||||
|
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||||
|
threshold: M3U_LARGE_THRESHOLD
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error estimating M3U size:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single source
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = await sources.getById(req.params.id);
|
||||||
|
if (!source) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
res.json(source);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create source
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, name, url, username, password } = req.body;
|
||||||
|
|
||||||
|
if (!type || !name || !url) {
|
||||||
|
return res.status(400).json({ error: 'Type, name, and URL are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['xtream', 'm3u', 'epg'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid source type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ownerId = null;
|
||||||
|
if (isAdminRole(req.user)) {
|
||||||
|
ownerId = parseOwnerId(req.body);
|
||||||
|
} else {
|
||||||
|
const uid = req.user.id != null ? Number(req.user.id) : NaN;
|
||||||
|
ownerId = Number.isNaN(uid) ? null : uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await sources.create({ type, name, url, username, password, ownerId });
|
||||||
|
syncService.syncSource(source.id).catch(console.error);
|
||||||
|
res.status(201).json(source);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update source
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await sources.getById(req.params.id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, existing)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, url, username, password } = req.body;
|
||||||
|
const updates = {
|
||||||
|
name: name || existing.name,
|
||||||
|
url: url || existing.url,
|
||||||
|
username: username !== undefined ? username : existing.username,
|
||||||
|
password: password !== undefined ? password : existing.password
|
||||||
|
};
|
||||||
|
if (isAdminRole(req.user) && req.body.ownerId !== undefined) {
|
||||||
|
updates.ownerId = parseOwnerId(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await sources.update(req.params.id, updates);
|
||||||
|
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete source
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sourceId = parseInt(req.params.id, 10);
|
||||||
|
const existing = await sources.getById(sourceId);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, existing)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
|
||||||
|
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
|
||||||
|
const deleteEpg = db.prepare('DELETE FROM epg_programs WHERE source_id = ?');
|
||||||
|
const deleteSyncStatus = db.prepare('DELETE FROM sync_status WHERE source_id = ?');
|
||||||
|
|
||||||
|
const catResult = deleteCategories.run(sourceId);
|
||||||
|
const itemResult = deleteItems.run(sourceId);
|
||||||
|
const epgResult = deleteEpg.run(sourceId);
|
||||||
|
deleteSyncStatus.run(sourceId);
|
||||||
|
|
||||||
|
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
|
||||||
|
|
||||||
|
await sources.delete(sourceId);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle source enabled/disabled
|
||||||
|
router.post('/:id/toggle', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await sources.getById(req.params.id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, existing)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await sources.toggleEnabled(req.params.id);
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.enabled) {
|
||||||
|
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to toggle source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual Sync
|
||||||
|
router.post('/:id/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const source = await sources.getById(id);
|
||||||
|
if (!source) return res.status(404).json({ error: 'Source not found' });
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
syncService.syncSource(id).catch(console.error);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Sync started' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting sync:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to start sync' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test source connection
|
||||||
|
router.post('/:id/test', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const source = await sources.getById(req.params.id);
|
||||||
|
if (!source) {
|
||||||
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === 'xtream') {
|
||||||
|
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} else if (source.type === 'm3u') {
|
||||||
|
const response = await fetch(source.url);
|
||||||
|
const text = await response.text();
|
||||||
|
const isValid = text.includes('#EXTM3U');
|
||||||
|
res.json({ success: isValid, message: isValid ? 'Valid M3U playlist' : 'Invalid M3U format' });
|
||||||
|
} else if (source.type === 'epg') {
|
||||||
|
const response = await fetch(source.url);
|
||||||
|
const text = await response.text();
|
||||||
|
const isValid = text.includes('<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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { spawn, execFile } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const db = require('../db');
|
||||||
|
const transcodeSession = require('../services/transcodeSession');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
||||||
|
* Times out quickly (8 s) so it doesn't block session creation for long.
|
||||||
|
*/
|
||||||
|
function probeDuration(url, ffprobePath, userAgent) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const args = [
|
||||||
|
'-v', 'quiet',
|
||||||
|
'-print_format', 'json',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-user_agent', userAgent || 'Mozilla/5.0',
|
||||||
|
'-probesize', '3000000',
|
||||||
|
'-analyzeduration', '2000000',
|
||||||
|
url
|
||||||
|
];
|
||||||
|
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (err, stdout) => {
|
||||||
|
if (err) { resolve(0); return; }
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stdout);
|
||||||
|
const dur = parseFloat(data?.format?.duration);
|
||||||
|
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
|
||||||
|
} catch { resolve(0); }
|
||||||
|
});
|
||||||
|
// Ensure the process is killed if execFile timeout fires
|
||||||
|
proc.on('error', () => resolve(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcode Routes
|
||||||
|
*
|
||||||
|
* Direct streaming (backward compatible):
|
||||||
|
* GET /api/transcode?url=...
|
||||||
|
*
|
||||||
|
* HLS session-based (new, supports seeking):
|
||||||
|
* POST /api/transcode/session - Create new session
|
||||||
|
* GET /api/transcode/:id/stream.m3u8 - Get HLS playlist
|
||||||
|
* GET /api/transcode/:id/:segment.ts - Get segment file
|
||||||
|
* DELETE /api/transcode/:id - Stop and cleanup session
|
||||||
|
* GET /api/transcode/sessions - List all sessions (debug)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Start session cleanup interval
|
||||||
|
transcodeSession.startCleanupInterval();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new transcode session
|
||||||
|
* POST /api/transcode/session
|
||||||
|
* Body: { url: string, seekOffset?: number }
|
||||||
|
*/
|
||||||
|
router.post('/session', async (req, res) => {
|
||||||
|
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||||
|
const settings = await db.settings.get();
|
||||||
|
const userAgent = db.getUserAgent(settings);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
|
||||||
|
|
||||||
|
// Probe duration and start session in parallel (don't block on duration)
|
||||||
|
const [session, durationSec] = await Promise.all([
|
||||||
|
transcodeSession.createSession(url, {
|
||||||
|
ffmpegPath,
|
||||||
|
userAgent,
|
||||||
|
seekOffset: seekOffset || 0,
|
||||||
|
hwEncoder: settings.hwEncoder || 'software',
|
||||||
|
maxResolution: settings.maxResolution || '1080p',
|
||||||
|
quality: settings.quality || 'medium',
|
||||||
|
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||||
|
upscaleEnabled: settings.upscaleEnabled || false,
|
||||||
|
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||||
|
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||||
|
videoMode: videoMode,
|
||||||
|
videoCodec: videoCodec,
|
||||||
|
audioCodec: audioCodec,
|
||||||
|
audioChannels: audioChannels
|
||||||
|
}).then(async s => { await s.start(); return s; }),
|
||||||
|
probeDuration(url, ffprobePath, userAgent)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for playlist to be ready (first segments generated)
|
||||||
|
const ready = await session.waitForPlaylist(15000);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
await transcodeSession.removeSession(session.id);
|
||||||
|
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sessionId: session.id,
|
||||||
|
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||||
|
status: session.status,
|
||||||
|
durationSec: durationSec || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transcode] Session creation failed:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create session', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HLS playlist for a session
|
||||||
|
* GET /api/transcode/:sessionId/stream.m3u8
|
||||||
|
*/
|
||||||
|
router.get('/:sessionId/stream.m3u8', async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = transcodeSession.getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = await session.getPlaylist();
|
||||||
|
if (!playlist) {
|
||||||
|
return res.status(404).json({ error: 'Playlist not ready' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.send(playlist);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a segment file for a session
|
||||||
|
* GET /api/transcode/:sessionId/:segment.ts
|
||||||
|
*/
|
||||||
|
router.get('/:sessionId/:segment', async (req, res) => {
|
||||||
|
const { sessionId, segment } = req.params;
|
||||||
|
|
||||||
|
// Only handle .ts files
|
||||||
|
if (!segment.endsWith('.ts')) {
|
||||||
|
return res.status(404).json({ error: 'Invalid segment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = transcodeSession.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentPath = await session.getSegment(segment);
|
||||||
|
if (!segmentPath) {
|
||||||
|
return res.status(404).json({ error: 'Segment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'video/MP2T');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache forever (immutable)
|
||||||
|
res.sendFile(segmentPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and cleanup a session
|
||||||
|
* DELETE /api/transcode/:sessionId
|
||||||
|
*/
|
||||||
|
router.delete('/:sessionId', async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transcodeSession.removeSession(sessionId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat — keeps a session alive while the player is active.
|
||||||
|
* POST /api/transcode/:sessionId/heartbeat
|
||||||
|
*/
|
||||||
|
router.post('/:sessionId/heartbeat', (req, res) => {
|
||||||
|
const session = transcodeSession.getSession(req.params.sessionId);
|
||||||
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||||
|
session.lastAccess = Date.now();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop session via POST (for sendBeacon on page close)
|
||||||
|
* POST /api/transcode/:sessionId/stop
|
||||||
|
*/
|
||||||
|
router.post('/:sessionId/stop', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await transcodeSession.removeSession(req.params.sessionId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active sessions (for debugging)
|
||||||
|
* GET /api/transcode/sessions
|
||||||
|
*/
|
||||||
|
router.get('/sessions', (req, res) => {
|
||||||
|
res.json(transcodeSession.getAllSessions());
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct transcode stream (backward compatible, no seeking)
|
||||||
|
* GET /api/transcode?url=...
|
||||||
|
*
|
||||||
|
* Transcodes audio to AAC for browser compatibility while passing video through.
|
||||||
|
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
|
||||||
|
*/
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||||
|
|
||||||
|
// Get User-Agent from settings
|
||||||
|
const settings = await db.settings.get();
|
||||||
|
const userAgent = db.getUserAgent(settings);
|
||||||
|
|
||||||
|
console.log(`[Transcode] Starting transcoding for: ${url}`);
|
||||||
|
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||||
|
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||||
|
|
||||||
|
// FFmpeg arguments for transcoding
|
||||||
|
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||||
|
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
||||||
|
const args = [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'warning',
|
||||||
|
'-user_agent', userAgent,
|
||||||
|
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||||
|
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||||
|
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||||
|
// Error resilience: generate timestamps, discard corrupt packets
|
||||||
|
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
||||||
|
// Ignore errors in stream and continue
|
||||||
|
'-err_detect', 'ignore_err',
|
||||||
|
// Limit max demux delay to prevent buffering issues
|
||||||
|
'-max_delay', '2000000',
|
||||||
|
// Reconnect settings for network drops (useful for live streams)
|
||||||
|
'-reconnect', '1',
|
||||||
|
'-reconnect_streamed', '1',
|
||||||
|
'-reconnect_delay_max', '3',
|
||||||
|
// Prevent Range/HEAD requests that some providers reject with 405
|
||||||
|
'-seekable', '0',
|
||||||
|
'-i', url,
|
||||||
|
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||||
|
'-map', '0:v:0',
|
||||||
|
'-map', '0:a:0?', // ? makes audio optional if not present
|
||||||
|
// Video: passthrough (no re-encoding = fast!)
|
||||||
|
'-c:v', 'copy',
|
||||||
|
// Audio: Transcode to browser-compatible AAC
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-b:a', '192k',
|
||||||
|
// Handle async audio/video using async filter
|
||||||
|
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
|
||||||
|
// Timestamp handling
|
||||||
|
'-fps_mode', 'passthrough',
|
||||||
|
'-async', '1',
|
||||||
|
'-max_muxing_queue_size', '2048',
|
||||||
|
// Fragmented MP4 for streaming (browser-compatible)
|
||||||
|
'-f', 'mp4',
|
||||||
|
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
||||||
|
'-flush_packets', '1', // Send data immediately
|
||||||
|
'-' // Output to stdout
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
let ffmpeg;
|
||||||
|
try {
|
||||||
|
ffmpeg = spawn(ffmpegPath, args);
|
||||||
|
} catch (spawnErr) {
|
||||||
|
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
|
||||||
|
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect stderr for error reporting
|
||||||
|
let stderrBuffer = '';
|
||||||
|
|
||||||
|
// Set headers for fragmented MP4
|
||||||
|
res.setHeader('Content-Type', 'video/mp4');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
// Pipe stdout to response
|
||||||
|
ffmpeg.stdout.pipe(res);
|
||||||
|
|
||||||
|
// Log stderr (useful for debugging transcoding failures)
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
const msg = data.toString();
|
||||||
|
stderrBuffer += msg;
|
||||||
|
console.log(`[FFmpeg] ${msg}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on client disconnect
|
||||||
|
req.on('close', () => {
|
||||||
|
console.log('[Transcode] Client disconnected, killing FFmpeg process');
|
||||||
|
ffmpeg.kill('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
ffmpeg.on('exit', (code) => {
|
||||||
|
if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill
|
||||||
|
console.error(`[Transcode] FFmpeg exited with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle spawn errors
|
||||||
|
ffmpeg.on('error', (err) => {
|
||||||
|
console.error('[Transcode] Failed to spawn FFmpeg:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Transcoding failed to start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
|
||||||
@@ -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,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();
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
/**
|
||||||
|
* Transcode Session Service
|
||||||
|
*
|
||||||
|
* Manages HLS transcoding sessions with segment caching for VOD seeking.
|
||||||
|
* Each session transcodes a source URL to HLS segments on disk.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Session-based transcoding with unique IDs
|
||||||
|
* - HLS segment output for seeking support
|
||||||
|
* - Segment caching for fast access
|
||||||
|
* - Session persistence for recovery after restart
|
||||||
|
* - Automatic cleanup of stale sessions
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const hwDetect = require('./hwDetect');
|
||||||
|
|
||||||
|
// Session storage
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
// Cache directory for transcoded segments
|
||||||
|
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
|
||||||
|
|
||||||
|
// Session settings
|
||||||
|
const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly)
|
||||||
|
const SEGMENT_DURATION = 4; // seconds per HLS segment
|
||||||
|
const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique session ID
|
||||||
|
*/
|
||||||
|
function generateSessionId() {
|
||||||
|
return crypto.randomBytes(8).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure cache directory exists
|
||||||
|
*/
|
||||||
|
async function ensureCacheDir() {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TranscodeSession class
|
||||||
|
* Manages a single transcoding session from source URL to HLS segments
|
||||||
|
*/
|
||||||
|
class TranscodeSession extends EventEmitter {
|
||||||
|
constructor(url, options = {}) {
|
||||||
|
super();
|
||||||
|
this.id = generateSessionId();
|
||||||
|
this.url = url;
|
||||||
|
this.dir = path.join(CACHE_DIR, this.id);
|
||||||
|
this.playlistPath = path.join(this.dir, 'stream.m3u8');
|
||||||
|
this.process = null;
|
||||||
|
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
||||||
|
this.status = 'pending'; // pending | starting | running | stopped | error
|
||||||
|
this.error = null;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.lastAccess = Date.now();
|
||||||
|
this.options = {
|
||||||
|
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
||||||
|
userAgent: options.userAgent || 'Mozilla/5.0',
|
||||||
|
seekOffset: options.seekOffset || 0,
|
||||||
|
hwEncoder: options.hwEncoder || 'software',
|
||||||
|
maxResolution: options.maxResolution || '1080p',
|
||||||
|
quality: options.quality || 'medium',
|
||||||
|
// Upscaling options
|
||||||
|
upscaleEnabled: options.upscaleEnabled || false,
|
||||||
|
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
||||||
|
upscaleTarget: options.upscaleTarget || '1080p',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the transcoding process
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.status === 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'starting';
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
|
||||||
|
|
||||||
|
// Create session directory
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.dir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build FFmpeg arguments for HLS output
|
||||||
|
const args = this.buildFFmpegArgs();
|
||||||
|
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.process = spawn(this.options.ffmpegPath, args, {
|
||||||
|
cwd: this.dir,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.status = 'running';
|
||||||
|
|
||||||
|
// Handle stdout (should be empty for file output)
|
||||||
|
this.process.stdout.on('data', (data) => {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stderr (FFmpeg progress/errors)
|
||||||
|
let stderrBuffer = '';
|
||||||
|
this.process.stderr.on('data', (data) => {
|
||||||
|
stderrBuffer += data.toString();
|
||||||
|
// Log periodically to avoid spam
|
||||||
|
const lines = stderrBuffer.split('\n');
|
||||||
|
if (lines.length > 1) {
|
||||||
|
lines.slice(0, -1).forEach(line => {
|
||||||
|
if (line.trim()) {
|
||||||
|
console.log(`[FFmpeg ${this.id}] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stderrBuffer = lines[lines.length - 1];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
if (code === 0 || code === null) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
||||||
|
this.status = 'stopped';
|
||||||
|
} else if (code !== 255) { // 255 is often from SIGKILL
|
||||||
|
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = `FFmpeg exited with code ${code}`;
|
||||||
|
}
|
||||||
|
this.process = null;
|
||||||
|
this.emit('exit', code);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle spawn errors
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
this.emit('error', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save session metadata
|
||||||
|
await this.persist();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
||||||
|
*/
|
||||||
|
buildFFmpegArgs() {
|
||||||
|
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
||||||
|
const videoMode = this.options.videoMode || 'encode';
|
||||||
|
|
||||||
|
// Resolve 'auto' encoder to detected hardware, fallback to software
|
||||||
|
let encoder = this.options.hwEncoder || 'software';
|
||||||
|
if (encoder === 'auto') {
|
||||||
|
const hwCaps = hwDetect.getCapabilities();
|
||||||
|
encoder = hwCaps?.recommended || 'software';
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'warning',
|
||||||
|
'-user_agent', this.options.userAgent,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add hardware acceleration input options based on encoder (only if encoding)
|
||||||
|
if (videoMode === 'encode') {
|
||||||
|
this.addHwAccelInputArgs(args, encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input options (common)
|
||||||
|
args.push(
|
||||||
|
'-probesize', '5000000',
|
||||||
|
'-analyzeduration', '5000000',
|
||||||
|
'-fflags', '+genpts+discardcorrupt',
|
||||||
|
'-err_detect', 'ignore_err',
|
||||||
|
'-reconnect', '1',
|
||||||
|
'-reconnect_streamed', '1',
|
||||||
|
'-reconnect_delay_max', '3'
|
||||||
|
);
|
||||||
|
|
||||||
|
args.push('-i', this.url);
|
||||||
|
|
||||||
|
// Add seek offset if specified (as output option to avoid Range requests)
|
||||||
|
if (this.options.seekOffset > 0) {
|
||||||
|
args.push('-ss', String(this.options.seekOffset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map streams
|
||||||
|
args.push('-map', '0:v:0');
|
||||||
|
args.push('-map', '0:a:0?');
|
||||||
|
|
||||||
|
// Add video encoder and filters based on selected encoder OR copy
|
||||||
|
if (videoMode === 'copy') {
|
||||||
|
args.push('-c:v', 'copy');
|
||||||
|
|
||||||
|
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
|
||||||
|
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
||||||
|
args.push('-bsf:v', 'hevc_mp4toannexb');
|
||||||
|
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
||||||
|
// Keep Annex-B conversion and force parameter sets to be repeated.
|
||||||
|
// This helps players recover on streams where SPS/PPS appear sporadically,
|
||||||
|
// which often manifests as "audio OK, black video".
|
||||||
|
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
|
||||||
|
} else {
|
||||||
|
// Fallback (e.g. unknown codec), try strict extraction
|
||||||
|
args.push('-bsf:v', 'dump_extra');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addVideoEncoderArgs(args, encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio: Apply mix preset
|
||||||
|
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
|
||||||
|
const audioChannels = this.options.audioChannels || 0;
|
||||||
|
const audioMixPreset = this.options.audioMixPreset || 'auto';
|
||||||
|
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
|
||||||
|
|
||||||
|
// Define pan filter presets for 5.1 -> Stereo downmix
|
||||||
|
const AUDIO_MIX_FILTERS = {
|
||||||
|
// ITU-R BS.775 Standard: Mathematically balanced, transparent
|
||||||
|
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
|
||||||
|
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
|
||||||
|
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
|
||||||
|
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
|
||||||
|
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (audioMixPreset === 'passthrough') {
|
||||||
|
// Passthrough: Always copy audio, no processing
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
|
||||||
|
args.push('-c:a', 'copy');
|
||||||
|
} else if (audioMixPreset === 'auto' && isStereoAac) {
|
||||||
|
// Auto + Stereo AAC source: Smart copy
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
|
||||||
|
args.push('-c:a', 'copy');
|
||||||
|
} else {
|
||||||
|
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
|
||||||
|
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
|
||||||
|
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
|
||||||
|
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
|
||||||
|
args.push(
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-b:a', '192k',
|
||||||
|
'-af', `${panFilter},aresample=async=1`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS output options
|
||||||
|
args.push(
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', String(SEGMENT_DURATION),
|
||||||
|
'-hls_list_size', '0', // Keep all segments in playlist
|
||||||
|
'-hls_flags', 'independent_segments+append_list',
|
||||||
|
'-hls_segment_type', 'mpegts',
|
||||||
|
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
|
||||||
|
this.playlistPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add hardware acceleration input arguments
|
||||||
|
*/
|
||||||
|
addHwAccelInputArgs(args, encoder) {
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
// NVIDIA CUDA/NVDEC hardware decoding
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'cuda',
|
||||||
|
'-hwaccel_output_format', 'cuda'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'vaapi':
|
||||||
|
// VAAPI hardware decoding (Linux)
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'vaapi',
|
||||||
|
'-hwaccel_device', '/dev/dri/renderD128',
|
||||||
|
'-hwaccel_output_format', 'vaapi'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'qsv':
|
||||||
|
// Intel QuickSync hardware decoding
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'qsv',
|
||||||
|
'-hwaccel_output_format', 'qsv'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'amf':
|
||||||
|
// AMD AMF (no hwaccel input, AMF is encode-only)
|
||||||
|
// Decode on CPU, encode on GPU
|
||||||
|
break;
|
||||||
|
case 'software':
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
// No hardware acceleration for input
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add video encoder arguments based on selected encoder
|
||||||
|
*/
|
||||||
|
addVideoEncoderArgs(args, encoder) {
|
||||||
|
const resolution = this.getTargetHeight();
|
||||||
|
const quality = this.options.quality || 'medium';
|
||||||
|
|
||||||
|
// Quality presets mapping
|
||||||
|
const qualityPresets = {
|
||||||
|
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
|
||||||
|
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
|
||||||
|
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
||||||
|
};
|
||||||
|
const qp = qualityPresets[quality] || qualityPresets.medium;
|
||||||
|
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
|
||||||
|
break;
|
||||||
|
case 'amf':
|
||||||
|
this.addAmfEncoderArgs(args, resolution, qp.amf);
|
||||||
|
break;
|
||||||
|
case 'vaapi':
|
||||||
|
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
|
||||||
|
break;
|
||||||
|
case 'qsv':
|
||||||
|
this.addQsvEncoderArgs(args, resolution, qp.qsv);
|
||||||
|
break;
|
||||||
|
case 'software':
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get target height based on maxResolution or upscaleTarget setting
|
||||||
|
* When upscaling is enabled, uses the upscaleTarget resolution.
|
||||||
|
* Otherwise, uses maxResolution to cap the output.
|
||||||
|
*/
|
||||||
|
getTargetHeight() {
|
||||||
|
const resolutionMap = {
|
||||||
|
'4k': 2160,
|
||||||
|
'1080p': 1080,
|
||||||
|
'720p': 720,
|
||||||
|
'480p': 480
|
||||||
|
};
|
||||||
|
|
||||||
|
// When upscaling is enabled, use the upscale target resolution
|
||||||
|
if (this.options.upscaleEnabled) {
|
||||||
|
const target = resolutionMap[this.options.upscaleTarget] || 1080;
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use max resolution as the cap
|
||||||
|
return resolutionMap[this.options.maxResolution] || 1080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build scale filter string based on encoder and upscaling settings
|
||||||
|
* @param {string} encoder - The encoder being used
|
||||||
|
* @param {number} height - Target height
|
||||||
|
*/
|
||||||
|
buildScaleFilter(encoder, height) {
|
||||||
|
const useUpscale = this.options.upscaleEnabled;
|
||||||
|
const upscaleMethod = this.options.upscaleMethod || 'hardware';
|
||||||
|
|
||||||
|
// Log upscaling status
|
||||||
|
if (useUpscale) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware scaling filters (for both upscale and downscale)
|
||||||
|
if (upscaleMethod === 'hardware' || !useUpscale) {
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
// NVIDIA CUDA scaling with Lanczos
|
||||||
|
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
||||||
|
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
||||||
|
case 'vaapi':
|
||||||
|
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
|
||||||
|
case 'qsv':
|
||||||
|
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
||||||
|
case 'amf':
|
||||||
|
// AMF uses CPU decode, so use software scale
|
||||||
|
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
||||||
|
case 'software':
|
||||||
|
default:
|
||||||
|
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Software Lanczos scaling (high quality, slower)
|
||||||
|
return `scale=-2:${height}:flags=lanczos`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NVIDIA NVENC encoder arguments
|
||||||
|
*/
|
||||||
|
addNvencEncoderArgs(args, height, qp) {
|
||||||
|
// Video filter for scaling on GPU
|
||||||
|
args.push('-vf', this.buildScaleFilter('nvenc', height));
|
||||||
|
|
||||||
|
// NVENC encoder with quality settings
|
||||||
|
// Using portable options that work across FFmpeg builds
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_nvenc',
|
||||||
|
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
||||||
|
'-rc', 'constqp', // Constant QP mode
|
||||||
|
'-qp', String(qp),
|
||||||
|
'-bf', '3' // B-frames for better compression
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMD AMF encoder arguments
|
||||||
|
*/
|
||||||
|
addAmfEncoderArgs(args, height, qp) {
|
||||||
|
// CPU decoding + software scale + AMF encode
|
||||||
|
args.push('-vf', this.buildScaleFilter('amf', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_amf',
|
||||||
|
'-quality', 'quality', // Quality preset
|
||||||
|
'-rc', 'cqp', // Constant QP
|
||||||
|
'-qp_i', String(qp),
|
||||||
|
'-qp_p', String(qp + 2),
|
||||||
|
'-qp_b', String(qp + 4),
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAAPI encoder arguments (Linux)
|
||||||
|
*/
|
||||||
|
addVaapiEncoderArgs(args, height, qp) {
|
||||||
|
// VAAPI filter chain:
|
||||||
|
// 1. scale_vaapi to resize on GPU
|
||||||
|
// 2. Ensure output format is nv12 for maximum encoder compatibility
|
||||||
|
// The format is handled automatically when using -hwaccel_output_format vaapi
|
||||||
|
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
||||||
|
|
||||||
|
// VAAPI encoder with quality setting
|
||||||
|
// Note: -global_quality is the portable way to set quality for VAAPI
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_vaapi',
|
||||||
|
'-profile:v', 'main', // Use main profile for compatibility
|
||||||
|
'-global_quality', String(qp),
|
||||||
|
'-bf', '3',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intel QuickSync encoder arguments
|
||||||
|
*/
|
||||||
|
addQsvEncoderArgs(args, height, qp) {
|
||||||
|
// Scale on QSV
|
||||||
|
args.push('-vf', this.buildScaleFilter('qsv', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_qsv',
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-global_quality', String(qp),
|
||||||
|
'-look_ahead', '1',
|
||||||
|
'-look_ahead_depth', '40',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Software encoder arguments (fallback)
|
||||||
|
*/
|
||||||
|
addSoftwareEncoderArgs(args, height, crf) {
|
||||||
|
// Software scaling (use Lanczos for upscaling if enabled)
|
||||||
|
args.push('-vf', this.buildScaleFilter('software', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'veryfast', // Fast for real-time
|
||||||
|
'-crf', String(crf),
|
||||||
|
'-profile:v', 'high',
|
||||||
|
'-level', '4.1',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the transcoding process
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.process) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
// Force kill after 2 seconds if still running
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
this.status = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last access time (prevents cleanup)
|
||||||
|
*/
|
||||||
|
touch() {
|
||||||
|
this.lastAccess = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if playlist exists and is ready
|
||||||
|
*/
|
||||||
|
async isPlaylistReady() {
|
||||||
|
try {
|
||||||
|
await fs.access(this.playlistPath);
|
||||||
|
const content = await fs.readFile(this.playlistPath, 'utf8');
|
||||||
|
// Check if playlist has at least one segment
|
||||||
|
return content.includes('.ts');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for playlist to be ready (with timeout)
|
||||||
|
* Fast-fails if FFmpeg exited with an error before the timeout.
|
||||||
|
*/
|
||||||
|
async waitForPlaylist(timeoutMs = 10000) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
if (await this.isPlaylistReady()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fast-fail: FFmpeg already exited with an error — no point waiting
|
||||||
|
if (this.status === 'error' && !this.process) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HLS playlist content
|
||||||
|
*/
|
||||||
|
async getPlaylist() {
|
||||||
|
this.touch();
|
||||||
|
try {
|
||||||
|
return await fs.readFile(this.playlistPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific segment
|
||||||
|
*/
|
||||||
|
async getSegment(segmentName) {
|
||||||
|
this.touch();
|
||||||
|
const segmentPath = path.join(this.dir, segmentName);
|
||||||
|
try {
|
||||||
|
await fs.access(segmentPath);
|
||||||
|
return segmentPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save session metadata to disk for recovery
|
||||||
|
*/
|
||||||
|
async persist() {
|
||||||
|
const metadata = {
|
||||||
|
id: this.id,
|
||||||
|
url: this.url,
|
||||||
|
status: this.status,
|
||||||
|
startTime: this.startTime,
|
||||||
|
lastAccess: this.lastAccess,
|
||||||
|
options: this.options,
|
||||||
|
seekOffset: this.options.seekOffset
|
||||||
|
};
|
||||||
|
const metaPath = path.join(this.dir, 'session.json');
|
||||||
|
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a session from disk metadata
|
||||||
|
*/
|
||||||
|
static async restore(sessionDir) {
|
||||||
|
const metaPath = path.join(sessionDir, 'session.json');
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(metaPath, 'utf8');
|
||||||
|
const metadata = JSON.parse(data);
|
||||||
|
const session = new TranscodeSession(metadata.url, metadata.options);
|
||||||
|
session.id = metadata.id;
|
||||||
|
session.dir = sessionDir;
|
||||||
|
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
|
||||||
|
session.startTime = metadata.startTime;
|
||||||
|
session.lastAccess = metadata.lastAccess;
|
||||||
|
session.status = 'stopped'; // Not running after restart
|
||||||
|
return session;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete session directory and all segments
|
||||||
|
*/
|
||||||
|
async cleanup() {
|
||||||
|
this.stop();
|
||||||
|
try {
|
||||||
|
await fs.rm(this.dir, { recursive: true, force: true });
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Manager
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new transcode session
|
||||||
|
*/
|
||||||
|
async function createSession(url, options = {}) {
|
||||||
|
await ensureCacheDir();
|
||||||
|
const session = new TranscodeSession(url, options);
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an existing session by ID
|
||||||
|
*/
|
||||||
|
function getSession(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.touch();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a session for a URL (reuses existing if still valid)
|
||||||
|
*/
|
||||||
|
async function getOrCreateSession(url, options = {}) {
|
||||||
|
// Check for existing session with same URL
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (session.url === url && session.status === 'running') {
|
||||||
|
session.touch();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create new session
|
||||||
|
return createSession(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove a session
|
||||||
|
*/
|
||||||
|
async function removeSession(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
await session.cleanup();
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup stale sessions (idle for too long)
|
||||||
|
*/
|
||||||
|
async function cleanupStaleSessions() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
||||||
|
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
|
||||||
|
await removeSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover sessions from disk after server restart
|
||||||
|
*/
|
||||||
|
async function recoverSessions() {
|
||||||
|
try {
|
||||||
|
await fs.access(CACHE_DIR);
|
||||||
|
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const dirent of dirs) {
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
const sessionDir = path.join(CACHE_DIR, dirent.name);
|
||||||
|
const session = await TranscodeSession.restore(sessionDir);
|
||||||
|
if (session) {
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
console.log(`[TranscodeSession] Recovered session ${session.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Cache dir doesn't exist yet, that's fine
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
console.error('[TranscodeSession] Error recovering sessions:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start cleanup interval
|
||||||
|
*/
|
||||||
|
let cleanupInterval = null;
|
||||||
|
function startCleanupInterval() {
|
||||||
|
if (!cleanupInterval) {
|
||||||
|
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||||
|
cleanupInterval.unref(); // Don't prevent process exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions (for debugging/monitoring)
|
||||||
|
*/
|
||||||
|
function getAllSessions() {
|
||||||
|
return Array.from(sessions.values()).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
url: s.url,
|
||||||
|
status: s.status,
|
||||||
|
startTime: s.startTime,
|
||||||
|
lastAccess: s.lastAccess,
|
||||||
|
idleMs: Date.now() - s.lastAccess
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TranscodeSession,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
getOrCreateSession,
|
||||||
|
removeSession,
|
||||||
|
cleanupStaleSessions,
|
||||||
|
recoverSessions,
|
||||||
|
startCleanupInterval,
|
||||||
|
getAllSessions,
|
||||||
|
CACHE_DIR,
|
||||||
|
SEGMENT_DURATION
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const { isAdminRole } = require('./auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use a source for playback and API access.
|
||||||
|
* ownerId null/undefined: legacy shared — any authenticated user may use it.
|
||||||
|
* ownerId N: only that user id (and admins) may use it.
|
||||||
|
*/
|
||||||
|
function canUserAccessSource(user, source) {
|
||||||
|
if (!user || !source) return false;
|
||||||
|
if (isAdminRole(user)) return true;
|
||||||
|
const oid = source.ownerId;
|
||||||
|
if (oid == null || oid === undefined) return true;
|
||||||
|
const uid = user.id != null ? Number(user.id) : NaN;
|
||||||
|
const oidN = Number(oid);
|
||||||
|
return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessibleSourceIds(req, sourcesApi) {
|
||||||
|
const all = await sourcesApi.getAll();
|
||||||
|
return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false after sending res if missing, forbidden, or invalid. */
|
||||||
|
async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) {
|
||||||
|
const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10);
|
||||||
|
if (Number.isNaN(sid)) {
|
||||||
|
res.status(400).json({ error: 'Invalid source id' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const source = await sourcesApi.getById(sid);
|
||||||
|
if (!source) {
|
||||||
|
res.status(404).json({ error: 'Source not found' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
|
res.status(403).json({ error: 'No access to this source' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canUserAccessSource,
|
||||||
|
getAccessibleSourceIds,
|
||||||
|
assertCanUseSourceById
|
||||||
|
};
|
||||||