Initial commit

This commit is contained in:
Trevor Mears
2025-12-27 14:16:40 -08:00
commit 4f9ec55874
30 changed files with 9148 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
data/*.db
.env
*.log
data/
user_data/
coverage/
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine
# Install build dependencies for better-sqlite3
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 3000
# Start server
CMD ["node", "server/index.js"]
+173
View File
@@ -0,0 +1,173 @@
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an "as-is" basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can use the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, material used under an exception or limitation to copyright, and material owned by others. Creative Commons provides considerations for licensors and other legal tools to assist licensors in managing their rights.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor's permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has the authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. Creative Commons provides considerations for the public to assist the public in using licensed material.
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 – Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, sound recording or Adapted Material includes the Licensed Material in synchronization with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses as a compatible license.
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
h. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
i. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
j. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used for other than NonCommercial purposes.
Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights and other applicable rights also apply.
Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon or waiver of any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
+102
View File
@@ -0,0 +1,102 @@
# NodeCast TV
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.
- **⚡ 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).
- **🐳 Docker Ready**: Easy deployment containerization.
## Getting Started
### Prerequisites
- Node.js (v14 or higher)
- npm
### Installation
1. Clone the repository:
```bash
```bash
git clone https://github.com/yourusername/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
Build and run the container using Docker Compose:
```bash
docker-compose up -d --build
```
The application will be available at `http://localhost:3000`.
## Technology Stack
- **Backend**: Node.js, Express
- **Frontend**: Vanilla JavaScript (ES6+), CSS3
- **Database**: JSON-based local storage (LowDB style)
- **Streaming**: HLS.js for stream playback
## 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
│ ├── services/ # Playlist parsers & logic
│ └── db.js # Data persistence layer
└── data/ # Persistent storage (playlists, settings)
```
## 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.
## License
This project is licensed under the **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)** license.
You are free to:
- **Share** — copy and redistribute the material in any medium or format
- **Adapt** — remix, transform, and build upon the material
Under the following terms:
- **Attribution** — You must give appropriate credit.
- **NonCommercial** — You may not use the material for commercial purposes.
- **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
See the [LICENSE](LICENSE) file for details.
+11
View File
@@ -0,0 +1,11 @@
services:
nodecast-tv:
build: .
container_name: nodecast-tv
ports:
- "3000:3000"
volumes:
- ./data:/app/data
restart: unless-stopped
environment:
- NODE_ENV=production
+859
View File
@@ -0,0 +1,859 @@
{
"name": "trev-tv",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trev-tv",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"xml2js": "^0.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "nodecast-tv",
"version": "1.0.0",
"description": "Modern IPTV Player",
"license": "CC-BY-NC-SA-4.0",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js"
},
"dependencies": {
"express": "^4.18.2",
"xml2js": "^0.6.2"
},
"engines": {
"node": ">=18.0.0"
}
}
+1886
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1a1a25"/>
<text x="50" y="55" text-anchor="middle" fill="#71717a" font-family="sans-serif" font-size="12">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeCast TV | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="stylesheet" href="css/main.css">
<!-- HLS.js for video playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
</head>
<body>
<div id="app">
<!-- Navigation -->
<nav class="navbar">
<div class="navbar-brand">
<span class="logo">📺</span>
<span class="brand-text">Trev-TV</span>
</div>
<div class="navbar-menu">
<a href="#" class="nav-link active" data-page="home">
<span class="nav-icon">📡</span>
<span>Live TV</span>
</a>
<a href="#" class="nav-link" data-page="guide">
<span class="nav-icon">📋</span>
<span>TV Guide</span>
</a>
<a href="#" class="nav-link" data-page="movies">
<span class="nav-icon">🎬</span>
<span>Movies</span>
</a>
<a href="#" class="nav-link" data-page="series">
<span class="nav-icon">📺</span>
<span>Series</span>
</a>
<a href="#" class="nav-link" data-page="settings">
<span class="nav-icon">⚙️</span>
<span>Settings</span>
</a>
</div>
</nav>
<!-- Main Content Area -->
<main class="main-content">
<!-- Home Page -->
<div id="page-home" class="page active">
<div class="home-layout">
<!-- Channel Sidebar -->
<aside class="channel-sidebar">
<div class="sidebar-header">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<div class="sidebar-controls">
<select id="source-select" class="source-select">
<option value="">All Sources</option>
</select>
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expand/Collapse All">
<span>⊟</span>
</button>
</div>
</div>
<div id="channel-list" class="channel-list">
<div class="empty-state">
<p>No channels loaded</p>
<p class="hint">Add a source in Settings to get started</p>
</div>
</div>
</aside>
<!-- Video Player Area -->
<section class="player-section">
<div class="video-container">
<video id="video-player" controls></video>
<div class="player-overlay" id="player-overlay">
<div class="overlay-content">
<p>Select a channel to start watching</p>
</div>
</div>
<!-- Now Playing Overlay -->
<div class="now-playing-overlay hidden" id="now-playing">
<div class="now-playing-info">
<h3 class="channel-name">No channel selected</h3>
<p class="program-title"></p>
<p class="program-time"></p>
</div>
<div class="up-next">
<h4>Up Next</h4>
<ul id="up-next-list"></ul>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Guide Page -->
<div id="page-guide" class="page">
<div class="guide-header">
<h2>TV Guide</h2>
<div class="guide-controls">
<input type="text" id="epg-search" placeholder="Search channels..." class="search-input"
style="min-width: 200px;">
<select id="epg-group-select" class="source-select">
<option value="">All Groups</option>
</select>
<button id="guide-prev" class="btn btn-ghost">&larr; Earlier</button>
<span id="guide-date" class="guide-date">Today</span>
<button id="guide-next" class="btn btn-ghost">Later &rarr;</button>
</div>
</div>
<div id="epg-grid" class="epg-grid">
<div class="empty-state">
<p>No EPG data available</p>
<p class="hint">Add an EPG source in Settings</p>
</div>
</div>
</div>
<!-- Movies Page -->
<div id="page-movies" class="page">
<div class="movies-header">
<h2>Movies</h2>
<div class="movies-controls">
<select id="movies-source-select" class="source-select">
<option value="">All Sources</option>
</select>
<select id="movies-category-select" class="source-select">
<option value="">All Categories</option>
</select>
<input type="text" id="movies-search" placeholder="Search movies..." class="search-input">
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
♥ Favorites
</button>
</div>
</div>
<div id="movies-grid" class="movies-grid">
<div class="empty-state">
<p>Loading movies...</p>
</div>
</div>
</div>
<!-- Series Page -->
<div id="page-series" class="page">
<div class="series-header">
<h2>Series</h2>
<div class="series-controls">
<select id="series-source-select" class="source-select">
<option value="">All Sources</option>
</select>
<select id="series-category-select" class="source-select">
<option value="">All Categories</option>
</select>
<input type="text" id="series-search" placeholder="Search series..." class="search-input">
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
♥ Favorites
</button>
</div>
</div>
<div class="series-content">
<div id="series-grid" class="series-grid">
<div class="empty-state">
<p>Loading series...</p>
</div>
</div>
<!-- Series details panel (seasons/episodes) -->
<div id="series-details" class="series-details hidden">
<button class="series-back-btn">← Back to Series</button>
<div class="series-details-header">
<img id="series-poster" src="" alt="" class="series-poster">
<div class="series-info">
<h3 id="series-title"></h3>
<p id="series-plot"></p>
</div>
</div>
<div id="series-seasons" class="series-seasons"></div>
</div>
</div>
</div>
<!-- Settings Page -->
<div id="page-settings" class="page">
<div class="settings-container">
<h2>Settings</h2>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="sources">Sources</button>
<button class="tab" data-tab="player">Player</button>
<button class="tab" data-tab="content">Manage Content</button>
</div>
<!-- Sources Tab -->
<div id="tab-sources" class="tab-content active">
<div class="source-section">
<div class="section-header">
<h3>Xtream Connections</h3>
<button class="btn btn-primary" id="add-xtream">+ Add Xtream</button>
</div>
<div id="xtream-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>M3U Playlists</h3>
<button class="btn btn-primary" id="add-m3u">+ Add M3U</button>
</div>
<div id="m3u-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>EPG Sources</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
</div>
<div id="epg-list" class="source-list"></div>
</div>
<div class="source-section">
<h3>EPG Data Settings</h3>
<div class="setting-item">
<label for="epg-refresh-interval">Auto-refresh EPG data every:</label>
<select id="epg-refresh-interval" class="form-input">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="12">12 hours</option>
<option value="24" selected>24 hours</option>
<option value="0">Manual only</option>
</select>
</div>
<p class="hint">Use the 🔄 button next to EPG sources for manual refresh</p>
</div>
</div>
<!-- Player Tab -->
<div id="tab-player" class="tab-content">
<div class="settings-section">
<h3>Keyboard Controls</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-arrow-keys" checked>
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Arrow keys change channels</span>
<span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Now Playing Overlay</h3>
<div class="setting-item">
<label for="setting-overlay-duration">Display Duration (seconds)</label>
<input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
value="5">
</div>
</div>
<div class="settings-section">
<h3>Volume Settings</h3>
<div class="setting-item">
<label for="setting-default-volume">Default Volume</label>
<div class="volume-slider-container">
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-remember-volume" checked>
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Remember Last Volume</span>
<span class="setting-hint">When enabled, volume persists between sessions</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Playback</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-autoplay-next">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Auto-play Next Episode</span>
<span class="setting-hint">Automatically play the next episode when one ends</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
</div>
</div>
</div>
<!-- Manage Content Tab -->
<div id="tab-content" class="tab-content">
<div class="content-browser">
<div class="content-browser-header">
<div class="content-type-toggle">
<button class="btn btn-sm active" id="content-type-channels">Channels</button>
<button class="btn btn-sm" id="content-type-movies">Movies</button>
<button class="btn btn-sm" id="content-type-series">Series</button>
</div>
<select id="content-source-select" class="source-select">
<option value="">Select a source...</option>
</select>
<div class="content-actions">
<button class="btn btn-sm btn-secondary" id="content-show-all">Show All</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</button>
</div>
</div>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Checked = Visible, ☐ Unchecked = Hidden</p>
<div id="content-tree" class="content-tree">
<p class="hint">Select a source to view groups and channels</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Modal Template -->
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Modal</h3>
<button class="modal-close">&times;</button>
</div>
<div id="modal-body" class="modal-body"></div>
<div id="modal-footer" class="modal-footer"></div>
</div>
</div>
<!-- Context Menu Template -->
<div id="context-menu" class="context-menu">
<button class="context-item" data-action="play">▶ Play</button>
<button class="context-item" data-action="hide">👁 Hide</button>
<button class="context-item" data-action="epg">📋 EPG Info</button>
</div>
<!-- JavaScript -->
<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/Home.js?v=3"></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/app.js?v=3"></script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
/**
* 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'
}
};
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) {
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`)
},
// 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 })
},
// 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) => API.request('GET', `/proxy/xtream/${sourceId}/live_categories`),
liveStreams: (sourceId, categoryId = null) =>
API.request('GET', `/proxy/xtream/${sourceId}/live_streams${categoryId ? `?category_id=${categoryId}` : ''}`),
vodCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/vod_categories`),
vodStreams: (sourceId, categoryId = null) =>
API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${categoryId ? `?category_id=${categoryId}` : ''}`),
seriesCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/series_categories`),
series: (sourceId, categoryId = null) =>
API.request('GET', `/proxy/xtream/${sourceId}/series${categoryId ? `?category_id=${categoryId}` : ''}`),
seriesInfo: (sourceId, seriesId) =>
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
},
// M3U
m3u: {
get: (sourceId) => API.request('GET', `/proxy/m3u/${sourceId}`)
},
// EPG
epg: {
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
}
}
};
// Make API available globally
window.API = API;
+74
View File
@@ -0,0 +1,74 @@
/**
* Trev-TV Application Entry Point
*/
class App {
constructor() {
this.currentPage = 'home';
this.pages = {};
// 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.guide = new GuidePage(this);
this.pages.movies = new MoviesPage(this);
this.pages.series = new SeriesPage(this);
this.pages.settings = new SettingsPage(this);
this.init();
}
async init() {
// Navigation handling
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo(link.dataset.page);
});
});
// Toggle groups button
document.getElementById('toggle-groups').addEventListener('click', () => {
this.channelList.toggleAllGroups();
});
// Initialize home page
await this.pages.home.init();
this.navigateTo('home');
console.log('NodeCast TV initialized');
}
navigateTo(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();
});
+964
View File
@@ -0,0 +1,964 @@
/**
* Channel List Component
* Handles the sidebar channel list
*/
class ChannelList {
constructor() {
this.container = document.getElementById('channel-list');
this.searchInput = document.getElementById('channel-search');
this.sourceSelect = document.getElementById('source-select');
this.showHiddenCheckbox = document.getElementById('show-hidden');
this.contextMenu = document.getElementById('context-menu');
this.channels = [];
this.groups = [];
this.hiddenItems = new Set(); // Set<"type:sourceId:itemId">
this.collapsedGroups = new Set(); // Track collapsed groups
this.favorites = []; // Array of favorite objects
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
this.currentChannel = null;
this.sources = [];
this.isLoading = false;
this.loadCollapsedState();
this.init();
}
/**
* Load collapsed state from localStorage
*/
loadCollapsedState() {
try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved));
}
} catch (err) {
console.error('Error loading collapsed state:', err);
}
}
/**
* Save collapsed state to localStorage
*/
saveCollapsedState() {
try {
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
} catch (err) {
console.error('Error saving collapsed state:', err);
}
}
/**
* Toggle group collapsed state
*/
toggleGroup(groupName) {
if (this.collapsedGroups.has(groupName)) {
this.collapsedGroups.delete(groupName);
} else {
this.collapsedGroups.add(groupName);
}
this.saveCollapsedState();
}
/**
* Expand all groups
*/
expandAll() {
this.collapsedGroups.clear();
this.saveCollapsedState();
this.container.querySelectorAll('.group-header.collapsed').forEach(h => h.classList.remove('collapsed'));
}
/**
* Collapse all groups
*/
collapseAll() {
this.container.querySelectorAll('.group-header').forEach(h => {
const groupName = h.dataset.group;
this.collapsedGroups.add(groupName);
h.classList.add('collapsed');
});
this.saveCollapsedState();
}
/**
* Toggle between expand/collapse all
*/
toggleAllGroups() {
const allHeaders = this.container.querySelectorAll('.group-header');
const allCollapsed = [...allHeaders].every(h => h.classList.contains('collapsed'));
if (allCollapsed) {
this.expandAll();
} else {
this.collapseAll();
}
}
init() {
// Search handler (debounced)
let searchTimeout;
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.render();
}, 300);
});
// Source filter handler
this.sourceSelect.addEventListener('change', () => this.loadChannels());
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
}
// Context menu handlers
document.addEventListener('click', () => this.hideContextMenu());
this.contextMenu.querySelectorAll('.context-item').forEach(item => {
item.addEventListener('click', (e) => this.handleContextAction(e));
});
// Intersection Observer for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextBatch();
}
}, { rootMargin: '100px' });
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* Get current program info string
*/
getProgramInfo(channel) {
try {
if (!window.app || !window.app.epgGuide) return null;
const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
return program ? program.title : null;
} catch (e) {
console.warn("Error in getProgramInfo", e);
return null;
}
}
escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Render channel list
*/
render() {
const searchTerm = this.searchInput.value.toLowerCase();
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
// Reset batching
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.container.innerHTML = ''; // Clear container
// Filter and Group channels
const groupedChannels = {};
// 1. Filter
this.filteredChannels = this.channels;
if (searchTerm) {
this.filteredChannels = this.channels.filter(ch =>
ch.name.toLowerCase().includes(searchTerm) ||
(ch.groupTitle && ch.groupTitle.toLowerCase().includes(searchTerm))
);
}
let filteredChannels = this.filteredChannels;
// 2. Group
filteredChannels.forEach(ch => {
const groupKey = ch.groupTitle || 'Uncategorized';
if (!groupedChannels[groupKey]) {
groupedChannels[groupKey] = [];
}
groupedChannels[groupKey].push(ch);
});
// 3. Add Favorites
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
}
// 4. Sort Groups
this.sortedGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
});
this.groupedChannels = groupedChannels;
this.showHidden = showHidden;
// Empty State
if (this.sortedGroups.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>${searchTerm ? 'No channels match your search' : 'No channels loaded'}</p>
<p class="hint">${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}</p>
</div>
`;
return;
}
// Wrap container content in a specific list div to append to
this.listContainer = document.createElement('div');
this.listContainer.className = 'channel-list-content';
this.container.appendChild(this.listContainer);
// Add loader element at bottom
this.loader = document.createElement('div');
this.loader.className = 'batch-loader';
this.loader.innerHTML = '<div class="loading-spinner"></div>';
this.loader.style.opacity = '0'; // Hide initially
this.container.appendChild(this.loader);
// Render initial batches - load enough to create scrollable content
// With many hidden groups, we may need multiple batches to get enough visible groups
const maxInitialBatches = 10; // Load up to 200 groups (10 batches x 20 groups)
for (let i = 0; i < maxInitialBatches; i++) {
if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break;
this.renderNextBatch();
}
// Start observing loader for additional batches
this.observer.observe(this.loader);
}
/**
* Render next batch of groups
*/
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const groupsToRender = this.sortedGroups.slice(start, end);
if (groupsToRender.length === 0) {
// No more groups
this.loader.style.display = 'none';
return;
}
this.loader.style.opacity = '1';
let html = '';
for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
const groupHidden = !isFavoritesGroup && channels.length > 0 && this.isHidden('group', channels[0].sourceId, groupName);
if (groupHidden && !this.showHidden) continue;
html += `
<div class="channel-group">
<div class="group-header ${groupHidden ? 'hidden' : ''} ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
<span class="group-toggle">▼</span>
<span class="group-name">${groupName}</span>
<span class="group-count">${channels.length}</span>
</div>
<div class="group-channels">
`;
for (const channel of channels) {
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, channel.id);
if (channelHidden && !this.showHidden) continue;
const isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-url="${channel.url || ''}">
<img class="channel-logo" src="${channel.tvgLogo || '/img/placeholder.png'}"
alt="" onerror="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>
</div>
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? '❤️' : '♡'}
</button>
</div>
`;
}
html += '</div></div>';
}
// Append to list container
// Use temp div to parse HTML string
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
while (tempDiv.firstElementChild) {
const groupEl = tempDiv.firstElementChild;
this.attachGroupListeners(groupEl);
this.listContainer.appendChild(groupEl);
}
this.currentBatch++;
// Hide loader if we might be done (next batch check will confirm)
if (end >= this.sortedGroups.length) {
this.loader.style.display = 'none';
}
}
attachGroupListeners(groupEl) {
const header = groupEl.querySelector('.group-header');
if (header) {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
this.toggleGroup(header.dataset.group);
});
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
}
groupEl.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) return;
this.selectChannel(item.dataset);
});
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
const favBtn = item.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId);
});
}
});
}
/**
* Load sources into dropdown
*/
async loadSources() {
try {
this.sources = await API.sources.getAll();
console.log('[ChannelList] loadSources: Got', this.sources?.length || 0, 'sources');
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
if (xtreamSources.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Xtream';
xtreamSources.forEach(s => {
const option = document.createElement('option');
option.value = `xtream:${s.id}`;
option.textContent = s.name;
optgroup.appendChild(option);
});
this.sourceSelect.appendChild(optgroup);
}
if (m3uSources.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'M3U';
m3uSources.forEach(s => {
const option = document.createElement('option');
option.value = `m3u:${s.id}`;
option.textContent = s.name;
optgroup.appendChild(option);
});
this.sourceSelect.appendChild(optgroup);
}
} catch (err) {
console.error('Error loading sources:', err);
}
}
/**
* Load channels from selected source
*/
async loadChannels() {
if (this.isLoading) return;
this.isLoading = true;
const sourceValue = this.sourceSelect.value;
const self = this;
if (!sourceValue) {
// Load from all sources
await this.loadAllChannels();
this.isLoading = false;
return;
}
const [type, id] = sourceValue.split(':');
try {
this.container.innerHTML = '<div class="loading"></div>';
if (type === 'xtream') {
await this.loadXtreamChannels(parseInt(id));
} else if (type === 'm3u') {
await this.loadM3uChannels(parseInt(id));
}
// Load hidden items and favorites
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
} catch (err) {
console.error('Error loading channels:', err);
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
} finally {
this.isLoading = false;
}
}
/**
* Load channels from all enabled sources
*/
async loadAllChannels() {
this.channels = [];
this.groups = [];
try {
this.container.innerHTML = '<div class="loading"></div>';
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
for (const source of xtreamSources) {
await this.loadXtreamChannels(source.id, true);
}
for (const source of m3uSources) {
await this.loadM3uChannels(source.id, true);
}
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
} catch (err) {
console.error('Error loading all channels:', err);
}
}
/**
* Load Xtream channels
*/
async loadXtreamChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups
const categoryGroups = categories.map(cat => ({
id: `xtream_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'xtream'
}));
this.groups.push(...categoryGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `xtream_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
groupId: `xtream_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => c.category_id === stream.category_id)?.category_name || 'Uncategorized',
sourceId,
sourceType: 'xtream'
}));
// Deduplicate by name within the same group (Xtream often sends backup streams with same name)
const seen = new Set();
const uniqueChannels = [];
for (const ch of channelList) {
const key = `${ch.groupTitle}|${ch.name}`;
if (!seen.has(key)) {
seen.add(key);
uniqueChannels.push(ch);
}
}
this.channels.push(...uniqueChannels);
}
/**
* Load M3U channels
*/
async loadM3uChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const data = await API.proxy.m3u.get(sourceId);
// Add groups
const m3uGroups = data.groups.map(g => ({
...g,
id: `m3u_${sourceId}_${g.id}`,
sourceId,
sourceType: 'm3u'
}));
this.groups.push(...m3uGroups);
// Add channels - use the stable id from the server
const channelList = data.channels.map(ch => ({
...ch,
// Use the stable id provided by the server (from tvgId or hash)
id: ch.id,
groupId: `m3u_${sourceId}_group_${data.groups.findIndex(g => g.name === ch.groupTitle)}`,
sourceId,
sourceType: 'm3u'
}));
this.channels.push(...channelList);
}
/**
* Load hidden items
*/
async loadHiddenItems() {
try {
const items = await API.channels.getHidden();
this.hiddenItems = new Set(items.map(i => `${i.item_type}:${i.source_id}:${i.item_id}`));
} catch (err) {
console.error('Error loading hidden items:', err);
}
}
/**
* Check if item is hidden
*/
isHidden(type, sourceId, itemId) {
return this.hiddenItems.has(`${type}:${sourceId}:${itemId}`);
}
/**
* Load favorites
*/
async loadFavorites() {
try {
// Get all favorites (filtered for channels or legacy items without type)
const allFavs = await API.favorites.getAll();
const channelFavs = allFavs.filter(f => !f.item_type || f.item_type === 'channel');
this.visibleFavorites = new Set(
channelFavs.map(f => `${f.source_id}:${f.item_id || f.channel_id}`)
);
} catch (err) {
console.error('Error loading favorites:', err);
}
}
/**
* Check if channel is favorite
*/
isFavorite(sourceId, channelId) {
return this.visibleFavorites.has(`${sourceId}:${channelId}`);
}
/**
* Toggle favorite status
*/
async toggleFavorite(sourceId, channelId) {
const key = `${sourceId}:${channelId}`;
const wasFavorite = this.visibleFavorites.has(key);
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
try {
// Optimistic update
if (wasFavorite) {
this.visibleFavorites.delete(key);
btns.forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
});
} else {
this.visibleFavorites.add(key);
btns.forEach(btn => {
btn.classList.add('active');
btn.innerHTML = '❤️';
btn.title = 'Remove from Favorites';
});
}
// Updates Favorites Group DOM
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) {
this.updateFavoritesGroup(channel, !wasFavorite);
}
// Do NOT call this.render() - it causes lag
// Perform API call
if (wasFavorite) {
await API.favorites.remove(sourceId, channelId, 'channel');
} else {
await API.favorites.add(sourceId, channelId, 'channel');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (wasFavorite) {
this.visibleFavorites.add(key);
btns.forEach(btn => {
btn.classList.add('active');
btn.innerHTML = '❤️';
});
// Revert group update
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, true);
} else {
this.visibleFavorites.delete(key);
btns.forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = '♡';
});
// Revert group update
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, false);
}
}
}
/**
* Update Favorites group in DOM and data
*/
updateFavoritesGroup(channel, isAdded) {
// 1. Update Data
if (!this.groupedChannels['Favorites']) {
this.groupedChannels['Favorites'] = [];
}
const favArray = this.groupedChannels['Favorites'];
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
if (isAdded) {
if (existingIdx === -1) favArray.push(channel);
} else {
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
}
// 2. Update DOM
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]');
if (!groupHeader) {
// If group doesn't exist and we're adding, we ideally should create it
// For now, simpler to just return. User will see it on next refresh.
// Or we could force a re-render if it's the first favorite?
if (isAdded && favArray.length === 1) {
this.render(); // This is the one case where full render is worth it
}
return;
}
const groupChannels = groupHeader.nextElementSibling; // .group-channels
const countSpan = groupHeader.querySelector('.group-count');
if (isAdded) {
// Check if already in DOM (to avoid dupes)
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
if (!existingEl) {
const newEl = this.createChannelElement(channel);
groupChannels.appendChild(newEl);
}
} else {
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
if (existingEl) {
existingEl.remove();
}
}
// Update count
if (countSpan) countSpan.textContent = favArray.length;
// Hide/Show group if empty?
if (favArray.length === 0) {
groupHeader.classList.add('hidden'); // Or remove
groupHeader.style.display = 'none';
} else {
groupHeader.classList.remove('hidden');
groupHeader.style.display = '';
}
}
createChannelElement(channel) {
const div = document.createElement('div');
const isActive = this.currentChannel?.id === channel.id;
// In Favorites group, it IS a favorite
const isFavorite = true;
div.className = `channel-item ${isActive ? 'active' : ''}`;
div.dataset.channelId = channel.id;
div.dataset.sourceId = channel.sourceId;
div.dataset.sourceType = channel.sourceType;
div.dataset.streamId = channel.streamId || '';
div.dataset.url = channel.url || '';
div.innerHTML = `
<img class="channel-logo" src="${channel.tvgLogo || '/img/placeholder.png'}"
alt="" onerror="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>
</div>
<button class="favorite-btn active" title="Remove from Favorites">
❤️
</button>
`;
// Attach listeners
div.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) return;
this.selectChannel(div.dataset);
});
div.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', div.dataset));
const favBtn = div.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(div.dataset.sourceId), div.dataset.channelId);
});
}
return div;
}
/**
* Select and play a channel
*/
async selectChannel(dataset) {
const channel = this.channels.find(c => c.id === dataset.channelId);
if (!channel) return;
this.currentChannel = channel;
// Update active state in DOM without full re-render
this.container.querySelectorAll('.channel-item.active').forEach(el => {
el.classList.remove('active');
});
const activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
// Get stream URL
let streamUrl;
if (channel.sourceType === 'xtream') {
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live');
streamUrl = result.url;
} else {
streamUrl = channel.url;
}
// Play channel
if (window.app?.player) {
window.app.player.play(channel, streamUrl);
}
}
/**
* Show context menu
*/
showContextMenu(e, type, data) {
e.preventDefault();
this.contextMenu.dataset.type = type;
this.contextMenu.dataset.sourceId = data.sourceId;
this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId;
this.contextMenu.style.left = `${e.clientX}px`;
this.contextMenu.style.top = `${e.clientY}px`;
this.contextMenu.classList.add('active');
}
/**
* Hide context menu
*/
hideContextMenu() {
this.contextMenu.classList.remove('active');
}
/**
* Handle context menu action
*/
async handleContextAction(e) {
const action = e.target.dataset.action;
const { type, sourceId, itemId } = this.contextMenu.dataset;
switch (action) {
case 'play':
if (type === 'channel') {
const channel = this.channels.find(c => c.id === itemId);
if (channel) {
await this.selectChannel({ channelId: channel.id });
}
}
break;
case 'hide':
await API.channels.hide(parseInt(sourceId), type, itemId);
this.hiddenItems.add(`${type}:${sourceId}:${itemId}`);
this.render();
break;
case 'epg':
// Show EPG info modal
this.showEpgInfo(itemId);
break;
}
this.hideContextMenu();
}
/**
* Sync favorite status from external source (e.g. EPG) without API call
*/
syncFavorite(sourceId, channelId, isFavorite) {
const key = `${sourceId}:${channelId}`;
const currentlyFav = this.visibleFavorites.has(key);
if (currentlyFav === isFavorite) return; // No change needed
// Update State
if (isFavorite) {
this.visibleFavorites.add(key);
} else {
this.visibleFavorites.delete(key);
}
// Update DOM (All instances)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
btns.forEach(btn => {
if (isFavorite) {
btn.classList.add('active');
btn.innerHTML = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
}
});
// Update Favorites Group
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) {
this.updateFavoritesGroup(channel, isFavorite);
}
}
/**
* Select next channel in the current list
*/
selectNextChannel() {
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
const currentIndex = this.filteredChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % this.filteredChannels.length;
const nextChannel = this.filteredChannels[nextIndex];
this.selectChannel({
channelId: nextChannel.id,
sourceId: nextChannel.sourceId,
sourceType: nextChannel.sourceType,
streamId: nextChannel.streamId,
url: nextChannel.url
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
const currentIndex = this.filteredChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.filteredChannels.length) % this.filteredChannels.length;
const prevChannel = this.filteredChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url
});
}
/**
* Show EPG info for channel
*/
async showEpgInfo(channelId) {
const channel = this.channels.find(c => c.id === channelId);
if (!channel) return;
// This would show a modal with EPG info
console.log('Show EPG for:', channel);
}
/**
* Get list of visible (non-hidden) channels in display order
*/
getVisibleChannels() {
const showHidden = this.showHiddenCheckbox.checked;
return this.channels.filter(ch => {
if (showHidden) return true;
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
return !channelHidden && !groupHidden;
});
}
}
// Export
window.ChannelList = ChannelList;
+756
View File
@@ -0,0 +1,756 @@
/**
* 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 = 3.5; // Width scaling
this.favorites = new Set(); // Set<"sourceId:channelId">
this.selectedGroup = 'Favorites'; // Default to Favorites
// Lazy loading properties
this.filteredChannels = [];
this.currentBatch = 0;
this.batchSize = 20; // Channels per batch
this.startTime = null;
this.endTime = null;
this.epgContainer = null;
this.epgLoader = null;
this.epgObserver = null;
this.init();
}
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();
}
/**
* Get EPG refresh interval from settings (in hours)
*/
getRefreshInterval() {
const saved = localStorage.getItem('nodecast_tv_epg_refresh_interval');
return saved ? parseInt(saved) : 24; // Default 24 hours
}
/**
* Load EPG data (server-side caching)
*/
async loadEpg(forceRefresh = false) {
try {
this.container.innerHTML = '<div class="loading"></div>';
await this.fetchEpgData(forceRefresh);
this.render();
} 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
const maxAge = this.getRefreshInterval();
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.channel_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,
desc: current.desc
} : 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)
const playableChannels = (channelList.channels || []).filter(ch => {
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, ch.id);
const isGroupHidden = channelList.isHidden('group', ch.sourceId, ch.groupTitle);
return !isChannelHidden && !isGroupHidden;
});
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);
// Build initial HTML structure
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-time-header">
${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 class="epg-channel-rows"></div>
<div class="epg-loader" style="height: 50px; display: flex; align-items: center; justify-content: center;">
<div class="loading-spinner"></div>
</div>
</div>
`;
// Get references for batch rendering
this.epgContainer = this.container.querySelector('.epg-channel-rows');
this.epgLoader = this.container.querySelector('.epg-loader');
// Reset batch state
this.currentBatch = 0;
// Set up IntersectionObserver for lazy loading
if (this.epgObserver) {
this.epgObserver.disconnect();
}
this.epgObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextEpgBatch();
}
}, { rootMargin: '200px' });
// Render initial batches (render enough to fill viewport)
for (let i = 0; i < 3; i++) {
this.renderNextEpgBatch();
}
// Start observing loader
this.epgObserver.observe(this.epgLoader);
// Add now indicator
this.updateNowIndicator();
}
/**
* Render next batch of EPG channel rows
*/
renderNextEpgBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredChannels.slice(start, end);
if (batch.length === 0) {
this.epgLoader.style.display = 'none';
return;
}
let html = '';
for (const { epgChannel, sourceChannel } of batch) {
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 = sourceChannel.tvgLogo || (epgChannel && epgChannel.icon) || '/img/placeholder.png';
const name = sourceChannel.name || (epgChannel && epgChannel.name);
html += `
<div class="epg-channel-row"
data-channel-id="${sourceChannel.id}"
data-source-id="${sourceChannel.sourceId}">
<div class="epg-channel-info">
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? '❤️' : '♡'}
</button>
<img class="epg-channel-logo" src="${logo}"
alt="" onerror="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>
</div>
`;
}
// Append to container
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
while (tempDiv.firstElementChild) {
const row = tempDiv.firstElementChild;
this.attachRowListeners(row);
this.epgContainer.appendChild(row);
}
this.currentBatch++;
// Hide loader if no more batches
if (end >= this.filteredChannels.length) {
this.epgLoader.style.display = 'none';
}
}
/**
* 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 = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
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');
}
} 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 = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
}
}
});
// Revert Channel List
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, wasFavorite);
}
}
}
/**
* 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();
const container = this.container.querySelector('.epg-container');
if (!container) return;
// Remove existing indicator
const existing = container.querySelector('.epg-now-line');
if (existing) existing.remove();
// Calculate position
const startTime = new Date();
startTime.setHours(startTime.getHours() + this.timeOffset);
startTime.setMinutes(0, 0, 0);
const minutesFromStart = (now - startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 240) return; // Not in visible range
// Get current sidebar width
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--epg-sidebar-width')) || 150;
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
const indicator = document.createElement('div');
indicator.className = 'epg-now-line';
indicator.style.left = `${leftPos}px`;
container.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 home page
document.querySelector('[data-page="home"]').click();
}
}
}
}
// Export
window.EpgGuide = EpgGuide;
+759
View File
@@ -0,0 +1,759 @@
/**
* Source Manager Component
* Handles adding, editing, and deleting sources (Xtream, M3U, EPG)
*/
class SourceManager {
constructor() {
this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list');
// Content browser state
this.contentType = 'channels'; // 'channels' or 'movies'
this.init();
}
init() {
// Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
document.getElementById('add-m3u').addEventListener('click', () => this.showAddModal('m3u'));
document.getElementById('add-epg').addEventListener('click', () => this.showAddModal('epg'));
// Initialize content browser
this.initContentBrowser();
}
/**
* Load and display all sources
*/
async loadSources() {
try {
const sources = await API.sources.getAll();
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
} catch (err) {
console.error('Error loading sources:', err);
}
}
/**
* Render source list
*/
renderSourceList(container, sources, type) {
if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return;
}
const icons = { xtream: '📡', m3u: '📋', epg: '📺' };
container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span>
<div class="source-info">
<div class="source-name">${source.name}</div>
<div class="source-url">${source.url}</div>
</div>
<div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">🔄</button>
<button class="btn btn-sm btn-secondary" data-action="test" title="Test Connection">🔗</button>
<button class="btn btn-sm btn-secondary" data-action="toggle" title="${source.enabled ? 'Disable' : 'Enable'}">
${source.enabled ? '✓' : '○'}
</button>
<button class="btn btn-sm btn-secondary" data-action="edit" title="Edit">✏️</button>
<button class="btn btn-sm btn-danger" data-action="delete" title="Delete">🗑️</button>
</div>
</div>
`).join('');
// Attach event listeners
container.querySelectorAll('.source-item').forEach(item => {
const id = parseInt(item.dataset.id);
item.querySelector('[data-action="refresh"]').addEventListener('click', () => this.refreshSource(id, type));
item.querySelector('[data-action="test"]').addEventListener('click', () => this.testSource(id));
item.querySelector('[data-action="toggle"]').addEventListener('click', () => this.toggleSource(id));
item.querySelector('[data-action="edit"]').addEventListener('click', () => this.showEditModal(id, type));
item.querySelector('[data-action="delete"]').addEventListener('click', () => this.deleteSource(id));
});
}
/**
* Show add source modal
*/
showAddModal(type) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type];
body.innerHTML = this.getSourceForm(type);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
<button class="btn btn-primary" id="modal-save">Add Source</button>
`;
modal.classList.add('active');
// Event listeners
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
document.getElementById('modal-save').onclick = () => this.saveNewSource(type);
}
/**
* Show edit source modal
*/
async showEditModal(id, type) {
try {
const source = await API.sources.getById(id);
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 = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = this.getSourceForm(type, source);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
<button class="btn btn-primary" id="modal-save">Save Changes</button>
`;
modal.classList.add('active');
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
document.getElementById('modal-save').onclick = () => this.updateSource(id, type);
} catch (err) {
console.error('Error loading source:', err);
}
}
/**
* Get source form HTML
*/
getSourceForm(type, source = {}) {
const nameField = `
<div class="form-group">
<label for="source-name">Name</label>
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
</div>
`;
const urlField = `
<div class="form-group">
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${source.url || ''}">
</div>
`;
if (type === 'xtream') {
return `
${nameField}
${urlField}
<div class="form-group">
<label for="source-username">Username</label>
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
</div>
<div class="form-group">
<label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input"
value="${source.password && !source.password.includes('•') ? source.password : ''}">
</div>
`;
}
return nameField + urlField;
}
/**
* Save new source
*/
async saveNewSource(type) {
const name = document.getElementById('source-name').value.trim();
const url = document.getElementById('source-url').value.trim();
const username = document.getElementById('source-username')?.value.trim() || null;
const password = document.getElementById('source-password')?.value.trim() || null;
if (!name || !url) {
alert('Name and URL are required');
return;
}
try {
await API.sources.create({ type, name, url, username, password });
document.getElementById('modal').classList.remove('active');
await this.loadSources();
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadSources();
await window.app.channelList.loadChannels();
}
} catch (err) {
alert('Error adding source: ' + err.message);
}
}
/**
* Update existing source
*/
async updateSource(id, type) {
const name = document.getElementById('source-name').value.trim();
const url = document.getElementById('source-url').value.trim();
const username = document.getElementById('source-username')?.value.trim();
const password = document.getElementById('source-password')?.value.trim();
if (!name || !url) {
alert('Name and URL are required');
return;
}
try {
const data = { name, url };
if (type === 'xtream') {
data.username = username;
if (password) data.password = password;
}
await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active');
await this.loadSources();
} catch (err) {
alert('Error updating source: ' + err.message);
}
}
/**
* Delete source
*/
async deleteSource(id) {
if (!confirm('Are you sure you want to delete this source?')) return;
try {
await API.sources.delete(id);
await this.loadSources();
if (window.app?.channelList) {
await window.app.channelList.loadSources();
await window.app.channelList.loadChannels();
}
} catch (err) {
alert('Error deleting source: ' + err.message);
}
}
/**
* Toggle source enabled/disabled
*/
async toggleSource(id) {
try {
await API.sources.toggle(id);
await this.loadSources();
} catch (err) {
alert('Error toggling source: ' + err.message);
}
}
/**
* Test source connection
*/
async testSource(id) {
try {
const result = await API.sources.test(id);
if (result.success) {
alert('Connection successful!');
} else {
alert('Connection failed: ' + (result.error || result.message));
}
} catch (err) {
alert('Connection failed: ' + err.message);
}
}
/**
* Refresh source data
*/
async refreshSource(id, type) {
try {
const btn = document.querySelector(`.source-item[data-id="${id}"] [data-action="refresh"]`);
if (btn) {
btn.disabled = true;
btn.textContent = '⏳';
}
if (type === 'epg') {
// Force refresh EPG data (server-side cache will be bypassed)
if (window.app?.epgGuide) {
await window.app.epgGuide.loadEpg(true); // Force refresh
}
alert('EPG data refreshed!');
} else if (type === 'xtream') {
// Re-fetch xtream data by reloading channels
if (window.app?.channelList) {
await window.app.channelList.loadChannels();
}
alert('Xtream data refreshed!');
} else if (type === 'm3u') {
// Re-fetch M3U data by reloading channels
if (window.app?.channelList) {
await window.app.channelList.loadChannels();
}
alert('M3U playlist refreshed!');
}
if (btn) {
btn.disabled = false;
btn.textContent = '🔄';
}
} catch (err) {
console.error('Error refreshing source:', err);
alert('Refresh failed: ' + err.message);
}
}
/**
* Initialize content browser
*/
initContentBrowser() {
this.contentSourceSelect = document.getElementById('content-source-select');
this.contentTree = document.getElementById('content-tree');
this.channelsBtn = document.getElementById('content-type-channels');
this.moviesBtn = document.getElementById('content-type-movies');
this.seriesBtn = document.getElementById('content-type-series');
// Content type toggle
this.channelsBtn?.addEventListener('click', () => {
this.contentType = 'channels';
this.channelsBtn.classList.add('active');
this.moviesBtn?.classList.remove('active');
this.seriesBtn?.classList.remove('active');
this.reloadContentTree();
});
this.moviesBtn?.addEventListener('click', () => {
this.contentType = 'movies';
this.moviesBtn.classList.add('active');
this.channelsBtn?.classList.remove('active');
this.seriesBtn?.classList.remove('active');
this.reloadContentTree();
});
this.seriesBtn?.addEventListener('click', () => {
this.contentType = 'series';
this.seriesBtn.classList.add('active');
this.channelsBtn?.classList.remove('active');
this.moviesBtn?.classList.remove('active');
this.reloadContentTree();
});
// Source selection
this.contentSourceSelect?.addEventListener('change', () => this.reloadContentTree());
// Show All / Hide All buttons
document.getElementById('content-show-all')?.addEventListener('click', () => this.setAllVisibility(true));
document.getElementById('content-hide-all')?.addEventListener('click', () => this.setAllVisibility(false));
}
/**
* Reload content tree based on current type and source
*/
reloadContentTree() {
const sourceId = this.contentSourceSelect?.value;
if (!sourceId) {
const typeLabel = this.contentType === 'movies' ? 'movie categories' :
this.contentType === 'series' ? 'series categories' : 'groups and channels';
this.contentTree.innerHTML = `<p class="hint">Select a source to view ${typeLabel}</p>`;
return;
}
if (this.contentType === 'movies') {
this.loadMovieCategoriesTree(parseInt(sourceId));
} else if (this.contentType === 'series') {
this.loadSeriesCategoriesTree(parseInt(sourceId));
} else {
this.loadContentTree(parseInt(sourceId));
}
}
/**
* Load sources into content browser dropdown
*/
async loadContentSources() {
try {
const sources = await API.sources.getAll();
const select = document.getElementById('content-source-select');
if (!select) return;
// Keep the placeholder option
select.innerHTML = '<option value="">Select a source...</option>';
sources.filter(s => s.type === 'xtream' || s.type === 'm3u').forEach(source => {
select.innerHTML += `<option value="${source.id}">${source.name} (${source.type})</option>`;
});
} catch (err) {
console.error('Error loading content sources:', err);
}
}
/**
* Load content tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadContentTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading...</p>';
try {
const source = await API.sources.getById(sourceId);
let channels = [];
let categoryMap = {};
if (source.type === 'xtream') {
// Run sequentially to avoid overwhelming the provider
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
channels = streams;
// Create map of category_id -> category_name
categories.forEach(cat => {
categoryMap[cat.category_id] = cat.category_name;
});
} else if (source.type === 'm3u') {
const m3uData = await API.proxy.m3u.get(sourceId);
channels = m3uData.channels || [];
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Group channels
const groups = {};
channels.forEach(ch => {
let groupName = 'Uncategorized';
if (source.type === 'xtream') {
// Use category map for Xtream
if (ch.category_id && categoryMap[ch.category_id]) {
groupName = categoryMap[ch.category_id];
}
} else {
// Use existing fields for M3U
groupName = ch.category_name || ch.groupTitle || 'Uncategorized';
}
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(ch);
});
// Render tree - checked = visible, unchecked = hidden
let html = '';
Object.keys(groups).sort().forEach(groupName => {
const groupHidden = hiddenSet.has(`group:${groupName}`);
const groupChannels = groups[groupName];
html += `
<div class="content-group collapsed" data-group="${groupName}">
<div class="content-group-header">
<span class="group-expander">▼</span>
<label class="checkbox-label" onclick="event.stopPropagation()">
<input type="checkbox" class="group-checkbox" data-type="group" data-id="${groupName}" data-source-id="${sourceId}" ${groupHidden ? '' : 'checked'}>
<span class="group-name">${groupName} (${groupChannels.length})</span>
</label>
</div>
<div class="content-channels">
${groupChannels.map(ch => {
const channelId = ch.stream_id || ch.id || ch.url;
const channelName = ch.name || ch.tvgName || 'Unknown';
const channelHidden = hiddenSet.has(`channel:${channelId}`);
return `
<label class="checkbox-label channel-item" title="${channelName}">
<input type="checkbox" class="channel-checkbox" data-type="channel" data-id="${channelId}" data-source-id="${sourceId}" ${channelHidden ? '' : 'checked'}>
<span class="channel-name">${channelName}</span>
</label>`;
}).join('')}
</div>
</div>`;
});
this.contentTree.innerHTML = html || '<p class="hint">No channels found</p>';
// Toggle group collapse on header click
this.contentTree.querySelectorAll('.content-group-header').forEach(header => {
header.addEventListener('click', () => {
const group = header.closest('.content-group');
group.classList.toggle('collapsed');
});
});
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
// Group checkbox toggles all children (BULK)
this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => {
groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb));
});
} catch (err) {
console.error('Error loading content tree:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading content</p>';
}
}
/**
* Load movie categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadMovieCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading movie categories...</p>';
try {
const source = await API.sources.getById(sourceId);
if (source.type !== 'xtream') {
this.contentTree.innerHTML = '<p class="hint">Movie categories are only available for Xtream sources</p>';
return;
}
// Fetch VOD categories
const categories = await API.proxy.xtream.vodCategories(sourceId);
if (!categories || categories.length === 0) {
this.contentTree.innerHTML = '<p class="hint">No movie categories found</p>';
return;
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Render categories - checked = visible, unchecked = hidden
let html = '<div class="content-categories">';
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
const isHidden = hiddenSet.has(`vod_category:${cat.category_id}`);
html += `
<label class="checkbox-label category-item">
<input type="checkbox" class="category-checkbox"
data-type="vod_category"
data-id="${cat.category_id}"
data-source-id="${sourceId}"
${isHidden ? '' : 'checked'}>
<span class="category-name">${cat.category_name}</span>
</label>`;
});
html += '</div>';
this.contentTree.innerHTML = html;
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
} catch (err) {
console.error('Error loading movie categories:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading movie categories</p>';
}
}
/**
* Load series categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadSeriesCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading series categories...</p>';
try {
const source = await API.sources.getById(sourceId);
if (source.type !== 'xtream') {
this.contentTree.innerHTML = '<p class="hint">Series categories are only available for Xtream sources</p>';
return;
}
// Fetch series categories
const categories = await API.proxy.xtream.seriesCategories(sourceId);
if (!categories || categories.length === 0) {
this.contentTree.innerHTML = '<p class="hint">No series categories found</p>';
return;
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Render categories - checked = visible, unchecked = hidden
let html = '<div class="content-categories">';
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
const isHidden = hiddenSet.has(`series_category:${cat.category_id}`);
html += `
<label class="checkbox-label category-item">
<input type="checkbox" class="category-checkbox"
data-type="series_category"
data-id="${cat.category_id}"
data-source-id="${sourceId}"
${isHidden ? '' : 'checked'}>
<span class="category-name">${cat.category_name}</span>
</label>`;
});
html += '</div>';
this.contentTree.innerHTML = html;
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
} catch (err) {
console.error('Error loading series categories:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading series categories</p>';
}
}
/**
* Toggle visibility of a single item
* Checked = show (remove from hidden), Unchecked = hide (add to hidden)
*/
async toggleVisibility(checkbox) {
const sourceId = parseInt(checkbox.dataset.sourceId);
const itemType = checkbox.dataset.type;
const itemId = checkbox.dataset.id;
const isVisible = checkbox.checked;
try {
// Fire API call (don't await for faster UI response)
const apiCall = isVisible
? API.channels.show(sourceId, itemType, itemId)
: API.channels.hide(sourceId, itemType, itemId);
// For VOD categories, don't refresh channel list (just let API complete in background)
if (itemType === 'vod_category' || itemType === 'series_category') {
apiCall.catch(err => {
console.error(`Error toggling ${itemType} visibility:`, err);
checkbox.checked = !isVisible; // Revert on error
});
return;
}
// For channels/groups, await and refresh
await apiCall;
// Refresh channel list if visible
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error toggling visibility:', err);
// Revert checkbox on error
checkbox.checked = !isVisible;
}
}
/**
* Toggle all children of a group efficiently
*/
async toggleGroupChildren(groupCb) {
const group = groupCb.closest('.content-group');
const channelCheckboxes = group.querySelectorAll('.channel-checkbox');
const isChecked = groupCb.checked;
const itemsToUpdate = [];
// Identify items that need changing
channelCheckboxes.forEach(chCb => {
if (chCb.checked !== isChecked) {
chCb.checked = isChecked;
itemsToUpdate.push({
sourceId: parseInt(chCb.dataset.sourceId),
itemType: chCb.dataset.type,
itemId: chCb.dataset.id
});
}
});
if (itemsToUpdate.length === 0) return;
try {
if (isChecked) {
await API.channels.bulkShow(itemsToUpdate);
} else {
await API.channels.bulkHide(itemsToUpdate);
}
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error toggling group children:', err);
// We might want to revert UI here, but it's complex for bulk items
}
}
/**
* Set visibility for all items
*/
async setAllVisibility(visible) {
const sourceId = this.contentSourceSelect?.value;
if (!sourceId) return;
const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]');
const items = [];
checkboxes.forEach(cb => {
if (cb.checked !== visible) {
items.push({
sourceId: parseInt(cb.dataset.sourceId),
itemType: cb.dataset.type,
itemId: cb.dataset.id
});
cb.checked = visible;
}
});
if (items.length === 0) return;
try {
if (visible) {
// Show all - use bulk API
await API.channels.bulkShow(items);
} else {
// Hide all - use bulk API
await API.channels.bulkHide(items);
}
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error setting all visibility:', err);
alert('Failed to update visibility');
}
}
}
// Export
window.SourceManager = SourceManager;
+505
View File
@@ -0,0 +1,505 @@
/**
* Video Player Component
* Handles HLS video playback with custom controls
*/
class VideoPlayer {
constructor() {
this.video = document.getElementById('video-player');
this.overlay = document.getElementById('player-overlay');
this.nowPlaying = document.getElementById('now-playing');
this.hls = null;
this.currentChannel = null;
this.overlayTimer = null;
this.overlayDuration = 5000; // 5 seconds
this.isUsingProxy = false;
this.currentUrl = null;
// Settings (loaded from localStorage)
this.settings = this.loadSettings();
this.init();
}
/**
* Load settings from localStorage
*/
loadSettings() {
const defaults = {
arrowKeysChangeChannel: true, // Use arrow keys for channel navigation
overlayDuration: 5, // Seconds
defaultVolume: 80, // 0-100
rememberVolume: true, // Persist volume between sessions
lastVolume: 80, // Last used volume (if rememberVolume is true)
autoPlayNextEpisode: false // Auto-play next episode in series
};
try {
const saved = localStorage.getItem('nodecast_tv_player_settings');
if (saved) {
return { ...defaults, ...JSON.parse(saved) };
}
} catch (err) {
console.error('Error loading settings:', err);
}
return defaults;
}
/**
* Save settings to localStorage
*/
saveSettings() {
try {
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
} catch (err) {
console.error('Error saving settings:', err);
}
}
init() {
// Apply default/remembered volume
const volume = this.settings.rememberVolume ? this.settings.lastVolume : this.settings.defaultVolume;
this.video.volume = volume / 100;
// Save volume changes
this.video.addEventListener('volumechange', () => {
if (this.settings.rememberVolume) {
this.settings.lastVolume = Math.round(this.video.volume * 100);
this.saveSettings();
}
});
// Initialize HLS.js if supported
if (Hls.isSupported()) {
this.hls = new Hls({
enableWorker: true,
lowLatencyMode: true
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// If it's a network error and we haven't tried the proxy yet, try it
if (this.currentChannel && !this.isUsingProxy) {
console.log('Network error detected, trying via proxy...');
const proxiedUrl = this.getProxiedUrl(this.currentUrl);
this.isUsingProxy = true;
this.hls.loadSource(proxiedUrl);
this.hls.startLoad();
} else {
console.log('Network error, attempting recovery...');
this.hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting recovery...');
this.hls.recoverMediaError();
break;
default:
this.stop();
break;
}
}
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
}
// Keyboard controls
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
// Click on video shows overlay
this.video.addEventListener('click', () => this.showNowPlayingOverlay());
}
/**
* Show the now playing overlay briefly
*/
showNowPlayingOverlay() {
if (!this.currentChannel) return;
// Clear existing timer
if (this.overlayTimer) {
clearTimeout(this.overlayTimer);
}
// Show overlay
this.nowPlaying.classList.remove('hidden');
// Hide after duration
this.overlayTimer = setTimeout(() => {
this.nowPlaying.classList.add('hidden');
}, this.settings.overlayDuration * 1000);
}
/**
* Hide the now playing overlay
*/
hideNowPlayingOverlay() {
if (this.overlayTimer) {
clearTimeout(this.overlayTimer);
}
this.nowPlaying.classList.add('hidden');
}
/**
* Play a channel
*/
async play(channel, streamUrl) {
this.currentChannel = channel;
try {
// Stop current playback
this.stop();
// Hide "select a channel" overlay
this.overlay.classList.add('hidden');
// Determine if HLS or direct stream
this.currentUrl = streamUrl;
// Proactively use proxy for known CORS-restricted domains (like Pluto TV)
const proxyRequiredDomains = ['pluto.tv'];
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
this.isUsingProxy = needsProxy;
const finalUrl = needsProxy ? this.getProxiedUrl(streamUrl) : streamUrl;
if (finalUrl.includes('.m3u8') && Hls.isSupported()) {
this.hls = new Hls();
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
// Re-attach error handler for the new Hls instance
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) {
console.log('CORS/Network error detected, retrying via proxy...');
this.isUsingProxy = true;
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
this.hls.startLoad();
} else {
console.error('Fatal HLS error:', data);
}
}
});
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
this.video.src = finalUrl;
this.video.play().catch(e => {
console.log('Autoplay prevented, trying proxy if CORS error:', e);
if (!this.isUsingProxy) {
this.isUsingProxy = true;
this.video.src = this.getProxiedUrl(streamUrl);
this.video.play().catch(err => console.error('Proxy play failed:', err));
}
});
} else {
// Try direct playback
this.video.src = finalUrl;
this.video.play().catch(e => console.log('Autoplay prevented:', e));
}
// Update now playing info
this.updateNowPlaying(channel);
// Show the now playing overlay
this.showNowPlayingOverlay();
// Fetch EPG data for this channel
this.fetchEpgData(channel);
// Dispatch event
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
} catch (err) {
console.error('Error playing channel:', err);
this.showError('Failed to play channel');
}
}
/**
* Fetch EPG data for current channel
*/
async fetchEpgData(channel) {
try {
// First, try to use the centralized EpgGuide data (already loaded)
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
const epgGuide = window.app.epgGuide;
// Get current program from EpgGuide
const currentProgram = epgGuide.getCurrentProgram(channel.tvgId, channel.name);
if (currentProgram) {
// Find upcoming programs from the guide's data
const epgChannel = epgGuide.channelMap?.get(channel.tvgId) ||
epgGuide.channelMap?.get(channel.name?.toLowerCase());
let upcoming = [];
if (epgChannel) {
const now = Date.now();
upcoming = epgGuide.programmes
.filter(p => p.channelId === epgChannel.id && new Date(p.start).getTime() > now)
.slice(0, 5)
.map(p => ({
title: p.title,
start: new Date(p.start),
stop: new Date(p.stop),
description: p.desc || ''
}));
}
this.updateNowPlaying(channel, {
current: {
title: currentProgram.title,
start: new Date(currentProgram.start),
stop: new Date(currentProgram.stop),
description: currentProgram.desc || ''
},
upcoming
});
return; // Success, exit early
}
}
// Fallback: Try to get EPG from Xtream API if available
if (channel.sourceType === 'xtream' && channel.streamId) {
const epgData = await API.proxy.xtream.shortEpg(channel.sourceId, channel.streamId);
if (epgData && epgData.epg_listings && epgData.epg_listings.length > 0) {
const listings = epgData.epg_listings;
const now = Math.floor(Date.now() / 1000);
// Find current program
const current = listings.find(p => {
const start = parseInt(p.start_timestamp);
const end = parseInt(p.stop_timestamp);
return start <= now && end > now;
});
// Get upcoming programs
const upcoming = listings
.filter(p => parseInt(p.start_timestamp) > now)
.slice(0, 5)
.map(p => ({
title: this.decodeBase64(p.title),
start: new Date(parseInt(p.start_timestamp) * 1000),
stop: new Date(parseInt(p.stop_timestamp) * 1000),
description: this.decodeBase64(p.description)
}));
if (current) {
this.updateNowPlaying(channel, {
current: {
title: this.decodeBase64(current.title),
start: new Date(parseInt(current.start_timestamp) * 1000),
stop: new Date(parseInt(current.stop_timestamp) * 1000),
description: this.decodeBase64(current.description)
},
upcoming
});
}
}
}
} catch (err) {
console.log('EPG data not available:', err.message);
}
}
/**
* Get proxied URL for a stream
*/
getProxiedUrl(url) {
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
}
/**
* Decode base64 EPG data
*/
decodeBase64(str) {
if (!str) return '';
try {
return decodeURIComponent(escape(atob(str)));
} catch {
return str;
}
}
/**
* Stop playback
*/
stop() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
this.video.pause();
this.video.src = '';
this.video.load();
}
/**
* Update now playing display
*/
updateNowPlaying(channel, epgData = null) {
const channelName = this.nowPlaying.querySelector('.channel-name');
const programTitle = this.nowPlaying.querySelector('.program-title');
const programTime = this.nowPlaying.querySelector('.program-time');
const upNextList = document.getElementById('up-next-list');
channelName.textContent = channel.name || channel.tvgName || 'Unknown Channel';
if (epgData && epgData.current) {
programTitle.textContent = epgData.current.title;
const start = new Date(epgData.current.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const end = new Date(epgData.current.stop).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
programTime.textContent = `${start} - ${end}`;
} else {
programTitle.textContent = '';
programTime.textContent = '';
}
// Update up next
upNextList.innerHTML = '';
if (epgData && epgData.upcoming) {
epgData.upcoming.slice(0, 3).forEach(prog => {
const li = document.createElement('li');
const time = new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
li.textContent = `${time} - ${prog.title}`;
upNextList.appendChild(li);
});
}
}
/**
* Show error overlay
*/
showError(message) {
this.overlay.classList.remove('hidden');
this.overlay.querySelector('.overlay-content').innerHTML = `<p style="color: var(--color-error);">${message}</p>`;
}
/**
* Handle keyboard shortcuts
*/
handleKeyboard(e) {
if (document.activeElement.tagName === 'INPUT') return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
this.video.paused ? this.video.play() : this.video.pause();
break;
case 'f':
e.preventDefault();
this.toggleFullscreen();
break;
case 'm':
e.preventDefault();
this.video.muted = !this.video.muted;
break;
case 'ArrowUp':
e.preventDefault();
if (this.settings.arrowKeysChangeChannel) {
this.channelUp();
} else {
this.video.volume = Math.min(1, this.video.volume + 0.1);
}
break;
case 'ArrowDown':
e.preventDefault();
if (this.settings.arrowKeysChangeChannel) {
this.channelDown();
} else {
this.video.volume = Math.max(0, this.video.volume - 0.1);
}
break;
case 'ArrowLeft':
e.preventDefault();
// Volume down when arrow keys are for channels
if (this.settings.arrowKeysChangeChannel) {
this.video.volume = Math.max(0, this.video.volume - 0.1);
}
break;
case 'ArrowRight':
e.preventDefault();
// Volume up when arrow keys are for channels
if (this.settings.arrowKeysChangeChannel) {
this.video.volume = Math.min(1, this.video.volume + 0.1);
}
break;
case 'PageUp':
case 'ChannelUp':
e.preventDefault();
this.channelUp();
break;
case 'PageDown':
case 'ChannelDown':
e.preventDefault();
this.channelDown();
break;
case 'i':
// Show/hide info overlay
e.preventDefault();
if (this.nowPlaying.classList.contains('hidden')) {
this.showNowPlayingOverlay();
} else {
this.hideNowPlayingOverlay();
}
break;
}
}
/**
* Go to previous channel
*/
channelUp() {
if (!window.app?.channelList) return;
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
}
/**
* Go to next channel
*/
channelDown() {
if (!window.app?.channelList) return;
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
}
/**
* Toggle fullscreen
*/
toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.video.requestFullscreen();
}
}
}
// Export
window.VideoPlayer = VideoPlayer;
+24
View File
@@ -0,0 +1,24 @@
/**
* Guide Page Controller
*/
class GuidePage {
constructor(app) {
this.app = app;
}
async init() {
// EPG guide will lazy load when shown
}
async show() {
// Always reload EPG data when page is shown to ensure fresh data
await this.app.epgGuide.loadEpg();
}
hide() {
// Page is hidden
}
}
window.GuidePage = GuidePage;
+99
View File
@@ -0,0 +1,99 @@
/**
* Home Page Controller
*/
class HomePage {
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();
// 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':
e.preventDefault();
this.app.channelList.selectPrevChannel();
break;
case 'ArrowDown':
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.HomePage = HomePage;
+398
View File
@@ -0,0 +1,398 @@
/**
* 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', () => {
this.loadCategories();
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 ? `⭐ ${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.src='/img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">▶</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">♥</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, 'vod', container);
if (result && result.url) {
// Navigate to home and play
this.app.navigateTo('home');
// Play the movie
if (this.app.player) {
const channel = {
id: `movie:${movie.stream_id}`,
name: movie.name,
tvgLogo: movie.stream_icon || movie.cover,
sourceType: 'xtream',
sourceId: movie.sourceId,
isVod: true // Mark as VOD for player handling
};
// For VOD, use direct URL (don't proxy through HLS)
// The video element can play mp4/mkv directly
this.app.player.play(channel, 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);
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
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');
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
}
}
}
}
window.MoviesPage = MoviesPage;
+481
View File
@@ -0,0 +1,481 @@
/**
* 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', () => {
this.loadCategories();
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 ? `⭐ ${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.src='/img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">▶</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">♥</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;
}
// 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">▼</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';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Navigate to home and play
this.app.navigateTo('home');
// Play the episode
if (this.app.player) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || 'Episode';
const channel = {
id: `episode:${episodeId}`,
name: `${this.currentSeries?.name || 'Series'} - ${episodeTitle}`,
sourceType: 'xtream',
sourceId: sourceId,
isVod: true
};
this.app.player.play(channel, 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);
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
await API.favorites.remove(series.sourceId, series.series_id, 'series');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
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');
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
}
}
}
}
window.SeriesPage = SeriesPage;
+120
View File
@@ -0,0 +1,120 @@
/**
* 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();
}
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', () => {
const value = Math.min(30, Math.max(1, parseInt(overlayDurationInput.value) || 5));
overlayDurationInput.value = value;
this.app.player.settings.overlayDuration = value;
this.app.player.saveSettings();
});
// Default volume slider
defaultVolumeSlider?.addEventListener('input', () => {
const value = parseInt(defaultVolumeSlider.value);
volumeValueDisplay.textContent = value + '%';
this.app.player.settings.defaultVolume = 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) {
// Load saved value
const savedInterval = localStorage.getItem('nodecast_tv_epg_refresh_interval');
if (savedInterval) {
epgRefreshSelect.value = savedInterval;
}
// Save on change
epgRefreshSelect.addEventListener('change', () => {
localStorage.setItem('nodecast_tv_epg_refresh_interval', epgRefreshSelect.value);
});
}
}
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();
}
}
async show() {
// Load sources when page is shown
await this.app.sourceManager.loadSources();
// Refresh player settings display
const arrowKeysToggle = document.getElementById('setting-arrow-keys');
const overlayDurationInput = document.getElementById('setting-overlay-duration');
if (this.app.player?.settings) {
arrowKeysToggle.checked = this.app.player.settings.arrowKeysChangeChannel;
overlayDurationInput.value = this.app.player.settings.overlayDuration;
}
}
hide() {
// Page is hidden
}
}
window.SettingsPage = SettingsPage;
+268
View File
@@ -0,0 +1,268 @@
const fs = require('fs');
const path = require('path');
// Ensure data directory exists
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'db.json');
// Initialize database structure
function loadDb() {
try {
if (fs.existsSync(dbPath)) {
const data = JSON.parse(fs.readFileSync(dbPath, 'utf-8'));
// Ensure all keys exist
return {
sources: data.sources || [],
hiddenItems: data.hiddenItems || [],
favorites: data.favorites || [],
nextId: data.nextId || 1
};
}
} catch (err) {
console.error('Error loading database:', err);
}
return {
sources: [],
hiddenItems: [],
favorites: [],
nextId: 1
};
}
function saveDb(data) {
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2));
}
// Source CRUD operations
const sources = {
getAll() {
const db = loadDb();
return db.sources;
},
getById(id) {
const db = loadDb();
return db.sources.find(s => s.id === parseInt(id));
},
getByType(type) {
const db = loadDb();
return db.sources.filter(s => s.type === type && s.enabled);
},
create(source) {
const db = loadDb();
const newSource = {
id: db.nextId++,
...source,
enabled: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
db.sources.push(newSource);
saveDb(db);
return newSource;
},
update(id, updates) {
const db = 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()
};
saveDb(db);
return db.sources[index];
},
delete(id) {
const db = loadDb();
db.sources = db.sources.filter(s => s.id !== parseInt(id));
// Also delete related hidden items
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
saveDb(db);
},
toggleEnabled(id) {
const db = loadDb();
const source = db.sources.find(s => s.id === parseInt(id));
if (source) {
source.enabled = !source.enabled;
source.updated_at = new Date().toISOString();
saveDb(db);
}
return source;
}
};
// Hidden items operations
const hiddenItems = {
getAll(sourceId = null) {
const db = loadDb();
if (sourceId) {
return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId));
}
return db.hiddenItems;
},
hide(sourceId, itemType, itemId) {
const db = 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
});
saveDb(db);
}
},
show(sourceId, itemType, itemId) {
const db = loadDb();
db.hiddenItems = db.hiddenItems.filter(
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
);
saveDb(db);
},
isHidden(sourceId, itemType, itemId) {
const db = loadDb();
return db.hiddenItems.some(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
);
},
bulkHide(items) {
const db = 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) {
saveDb(db);
}
return true;
},
bulkShow(items) {
const db = 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) {
saveDb(db);
}
return true;
}
};
// Favorites operations
const favorites = {
getAll(sourceId = null, itemType = null) {
const db = 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;
},
add(sourceId, itemId, itemType = 'channel') {
const db = 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()
});
saveDb(db);
}
return true;
},
remove(sourceId, itemId, itemType = 'channel') {
const db = loadDb();
db.favorites = db.favorites.filter(
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
);
saveDb(db);
return true;
},
isFavorite(sourceId, itemId, itemType = 'channel') {
const db = loadDb();
return db.favorites.some(
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
);
}
};
// EPG cache operations (simple in-memory for now)
const epgCache = {
cache: new Map(),
get(channelId) {
return this.cache.get(channelId);
},
set(sourceId, channelId, data) {
this.cache.set(channelId, { sourceId, data, updated_at: new Date() });
},
clear(sourceId = null) {
if (sourceId) {
for (const [key, value] of this.cache) {
if (value.sourceId === sourceId) {
this.cache.delete(key);
}
}
} else {
this.cache.clear();
}
}
};
module.exports = { sources, hiddenItems, favorites, epgCache };
+33
View File
@@ -0,0 +1,33 @@
const express = require('express');
const path = require('path');
// Initialize database
require('./db');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json({ limit: '50mb' }));
app.use(express.static(path.join(__dirname, '..', 'public')));
// API Routes
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'));
// 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, () => {
console.log(`NodeCast TV server running on http://localhost:${PORT}`);
});
+106
View File
@@ -0,0 +1,106 @@
const express = require('express');
const router = express.Router();
const { hiddenItems } = require('../db');
// Get all hidden items
router.get('/hidden', (req, res) => {
try {
const { sourceId } = req.query;
const items = hiddenItems.getAll(sourceId ? parseInt(sourceId) : null);
res.json(items);
} catch (err) {
console.error('Error getting hidden items:', err);
res.status(500).json({ error: 'Failed to get hidden items' });
}
});
// Hide a channel or group
router.post('/hide', (req, res) => {
try {
const { sourceId, itemType, itemId } = req.body;
if (!sourceId || !itemType || !itemId) {
return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' });
}
if (!['channel', 'group'].includes(itemType)) {
return res.status(400).json({ error: 'itemType must be "channel" or "group"' });
}
hiddenItems.hide(sourceId, itemType, itemId);
res.json({ success: true });
} catch (err) {
console.error('Error hiding item:', err);
res.status(500).json({ error: 'Failed to hide item' });
}
});
// Show (unhide) a channel or group
router.post('/show', (req, res) => {
try {
const { sourceId, itemType, itemId } = req.body;
if (!sourceId || !itemType || !itemId) {
return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' });
}
hiddenItems.show(sourceId, itemType, itemId);
res.json({ success: true });
} catch (err) {
console.error('Error showing item:', err);
res.status(500).json({ error: 'Failed to show item' });
}
});
// Check if item is hidden
router.get('/hidden/check', (req, res) => {
try {
const { sourceId, itemType, itemId } = req.query;
if (!sourceId || !itemType || !itemId) {
return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' });
}
const isHidden = hiddenItems.isHidden(parseInt(sourceId), itemType, itemId);
res.json({ hidden: isHidden });
} catch (err) {
console.error('Error checking hidden status:', err);
res.status(500).json({ error: 'Failed to check hidden status' });
}
});
// Bulk hide channels and groups
router.post('/hide/bulk', (req, res) => {
try {
const { items } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'items array is required' });
}
hiddenItems.bulkHide(items);
res.json({ success: true, count: items.length });
} catch (err) {
console.error('Error bulk hiding items:', err);
res.status(500).json({ error: 'Failed to bulk hide items' });
}
});
// Bulk show channels and groups
router.post('/show/bulk', (req, res) => {
try {
const { items } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'items array is required' });
}
hiddenItems.bulkShow(items);
res.json({ success: true, count: items.length });
} catch (err) {
console.error('Error bulk showing items:', err);
res.status(500).json({ error: 'Failed to bulk show items' });
}
});
module.exports = router;
+61
View File
@@ -0,0 +1,61 @@
const express = require('express');
const router = express.Router();
const { favorites } = require('../db');
// Get all favorites
router.get('/', (req, res) => {
try {
const { sourceId, itemType } = req.query;
const items = favorites.getAll(sourceId, itemType);
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Add favorite
router.post('/', (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(sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Remove favorite
router.delete('/', (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(sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Check if item is favorited
router.get('/check', (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(sourceId, itemId, itemType);
res.json({ isFavorite: isFav });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+287
View File
@@ -0,0 +1,287 @@
const express = require('express');
const router = express.Router();
const { sources } = require('../db');
const xtreamApi = require('../services/xtreamApi');
const m3uParser = require('../services/m3uParser');
const epgParser = require('../services/epgParser');
/**
* Proxy Xtream API calls
* GET /api/proxy/xtream/:sourceId/:action
*/
router.get('/xtream/:sourceId/:action', async (req, res) => {
try {
const source = sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const api = xtreamApi.createFromSource(source);
const { action } = req.params;
const { category_id, stream_id, vod_id, series_id, limit } = req.query;
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' });
}
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?', (req, res) => {
try {
const source = sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const api = xtreamApi.createFromSource(source);
const { streamId, type = 'live' } = req.params;
const { container = 'm3u8' } = req.query;
const url = api.buildStreamUrl(streamId, type, container);
res.json({ url });
} catch (err) {
console.error('Stream URL error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Fetch and parse M3U playlist
* GET /api/proxy/m3u/:sourceId
*/
router.get('/m3u/:sourceId', async (req, res) => {
try {
const source = sources.getById(req.params.sourceId);
if (!source || source.type !== 'm3u') {
return res.status(404).json({ error: 'M3U source not found' });
}
const data = await m3uParser.fetchAndParse(source.url);
res.json(data);
} catch (err) {
console.error('M3U proxy error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* EPG cache - in-memory for performance
* Structure: { [sourceId]: { data, fetchedAt } }
*/
const epgMemoryCache = {};
/**
* Fetch and parse EPG (with 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 = sources.getById(sourceId);
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
return res.status(404).json({ error: 'Valid EPG source not found' });
}
const forceRefresh = req.query.refresh === '1';
const maxAgeHours = parseInt(req.query.maxAge) || 24;
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
// Check cache (unless force refresh)
if (!forceRefresh && epgMemoryCache[sourceId]) {
const cached = epgMemoryCache[sourceId];
const age = Date.now() - cached.fetchedAt;
if (age < maxAgeMs) {
return res.json(cached.data);
}
}
// 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 cache
epgMemoryCache[sourceId] = {
data,
fetchedAt: Date.now()
};
res.json(data);
} catch (err) {
console.error('EPG proxy error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Clear EPG cache for a source
* DELETE /api/proxy/epg/:sourceId/cache
*/
router.delete('/epg/:sourceId/cache', (req, res) => {
const sourceId = req.params.sourceId;
delete epgMemoryCache[sourceId];
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 = sources.getById(req.params.sourceId);
if (!source || source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' });
}
const { channelIds } = req.body;
if (!channelIds || !Array.isArray(channelIds)) {
return res.status(400).json({ error: 'channelIds array required' });
}
const data = await epgParser.fetchAndParse(source.url);
// Filter programmes for requested channels
const result = {};
for (const channelId of channelIds) {
result[channelId] = epgParser.getCurrentAndUpcoming(data.programmes, channelId);
}
res.json(result);
} catch (err) {
console.error('EPG channels error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Proxy stream for playback
* This handles CORS for streams that don't allow cross-origin
*/
router.get('/stream', async (req, res) => {
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
const isPluto = url.includes('pluto.tv');
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 + '/'
};
const response = await fetch(url, { headers });
if (!response.ok) {
console.error(`Upstream error for ${url}: ${response.status} ${response.statusText}`);
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', '*');
// Check if it's an HLS manifest
const isHls = contentType.includes('mpegurl') || contentType.includes('application/x-mpegURL') || url.toLowerCase().includes('.m3u8');
if (isHls) {
let manifest = await response.text();
// Rewrite URLs inside manifest
if (manifest.trim().startsWith('#EXTM3U')) {
res.set('Content-Type', 'application/vnd.apple.mpegurl');
const urlObj = new URL(url);
const baseUrl = urlObj.origin + urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1);
manifest = manifest.split('\n').map(line => {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) {
if (trimmed.includes('URI="')) {
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;
}
try {
const 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 (segments)
res.set('Content-Type', contentType);
const buffer = await response.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (err) {
console.error('Stream proxy error:', err);
if (!res.headersSent) {
res.status(500).json({ error: err.message });
}
}
});
module.exports = router;
+147
View File
@@ -0,0 +1,147 @@
const express = require('express');
const router = express.Router();
const { sources } = require('../db');
const xtreamApi = require('../services/xtreamApi');
// Get all sources
router.get('/', (req, res) => {
try {
const allSources = sources.getAll();
// Don't expose passwords in list view
const sanitized = allSources.map(s => ({
...s,
password: s.password ? '••••••••' : null
}));
res.json(sanitized);
} catch (err) {
console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Get sources by type
router.get('/type/:type', (req, res) => {
try {
const typeSources = sources.getByType(req.params.type);
res.json(typeSources);
} catch (err) {
console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Get single source
router.get('/:id', (req, res) => {
try {
const source = sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
res.json(source);
} catch (err) {
console.error('Error getting source:', err);
res.status(500).json({ error: 'Failed to get source' });
}
});
// Create source
router.post('/', (req, res) => {
try {
const { type, name, url, username, password } = req.body;
if (!type || !name || !url) {
return res.status(400).json({ error: 'Type, name, and URL are required' });
}
if (!['xtream', 'm3u', 'epg'].includes(type)) {
return res.status(400).json({ error: 'Invalid source type' });
}
const source = sources.create({ type, name, url, username, password });
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', (req, res) => {
try {
const existing = sources.getById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
const { name, url, username, password } = req.body;
const updated = sources.update(req.params.id, {
name: name || existing.name,
url: url || existing.url,
username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password
});
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', (req, res) => {
try {
const existing = sources.getById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
sources.delete(req.params.id);
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', (req, res) => {
try {
const updated = sources.toggleEnabled(req.params.id);
if (!updated) {
return res.status(404).json({ error: 'Source not found' });
}
res.json(updated);
} catch (err) {
console.error('Error toggling source:', err);
res.status(500).json({ error: 'Failed to toggle source' });
}
});
// Test source connection
router.post('/:id/test', async (req, res) => {
try {
const source = sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (source.type === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
res.json({ success: true, data: result });
} else if (source.type === 'm3u') {
const response = await fetch(source.url);
const text = await response.text();
const isValid = text.includes('#EXTM3U');
res.json({ success: isValid, message: isValid ? 'Valid M3U playlist' : 'Invalid M3U format' });
} else if (source.type === 'epg') {
const response = await fetch(source.url);
const text = await response.text();
const isValid = text.includes('<tv') || text.includes('<?xml');
res.json({ success: isValid, message: isValid ? 'Valid EPG XML' : 'Invalid EPG format' });
}
} catch (err) {
console.error('Error testing source:', err);
res.json({ success: false, error: err.message });
}
});
module.exports = router;
+177
View File
@@ -0,0 +1,177 @@
/**
* EPG (XMLTV) Parser
* Parses XMLTV format EPG data and extracts channel/programme information
*/
const { parseString } = require('xml2js');
const { promisify } = require('util');
const parseXml = promisify(parseString);
/**
* 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
* @param {string} content - Raw XMLTV content
* @returns {Promise<{ channels: Array, programmes: Array }>}
*/
async function parse(content) {
const result = await parseXml(content, {
explicitArray: false,
mergeAttrs: true
});
if (!result.tv) {
throw new Error('Invalid XMLTV format: missing <tv> root element');
}
const tv = result.tv;
const channels = [];
const programmes = [];
// Parse channels
const channelList = Array.isArray(tv.channel) ? tv.channel : (tv.channel ? [tv.channel] : []);
for (const ch of channelList) {
const channel = {
id: ch.id,
name: extractText(ch['display-name']),
icon: ch.icon ? (ch.icon.src || ch.icon) : null,
url: extractText(ch.url)
};
channels.push(channel);
}
// Parse programmes
const programmeList = Array.isArray(tv.programme) ? tv.programme : (tv.programme ? [tv.programme] : []);
for (const prog of programmeList) {
const programme = {
channelId: prog.channel,
start: parseXmltvDate(prog.start),
stop: parseXmltvDate(prog.stop),
title: extractText(prog.title),
subtitle: extractText(prog['sub-title']),
description: extractText(prog.desc),
category: extractCategories(prog.category),
icon: prog.icon ? (prog.icon.src || prog.icon) : null,
date: extractText(prog.date),
episodeNum: extractEpisodeNum(prog['episode-num'])
};
programmes.push(programme);
}
return { channels, programmes };
}
/**
* Extract text from XMLTV element (handles both string and object formats)
*/
function extractText(element) {
if (!element) return null;
if (typeof element === 'string') return element;
if (Array.isArray(element)) {
// Prefer English or first item
const en = element.find(e => e.lang === 'en' || !e.lang);
return extractText(en || element[0]);
}
if (element._) return element._;
if (element['#text']) return element['#text'];
return String(element);
}
/**
* Extract categories array
*/
function extractCategories(category) {
if (!category) return [];
const cats = Array.isArray(category) ? category : [category];
return cats.map(c => extractText(c)).filter(Boolean);
}
/**
* Extract episode number
*/
function extractEpisodeNum(episodeNum) {
if (!episodeNum) return null;
const nums = Array.isArray(episodeNum) ? episodeNum : [episodeNum];
for (const num of nums) {
if (typeof num === 'string') return num;
if (num._ || num['#text']) {
return num._ || num['#text'];
}
}
return null;
}
/**
* 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}`);
}
const content = await response.text();
return parse(content);
}
module.exports = {
parse,
parseXmltvDate,
fetchAndParse,
getProgrammesForChannel,
getCurrentAndUpcoming
};
+155
View File
@@ -0,0 +1,155 @@
/**
* M3U Playlist Parser
* Parses EXTM3U format playlists and extracts channel information
*/
/**
* 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 M3U playlist content
* @param {string} content - Raw M3U playlist content
* @returns {{ channels: Array, groups: Array }}
*/
function parse(content) {
const lines = content.split('\n').map(line => line.trim());
const channels = [];
const groupsSet = new Set();
// Verify it's a valid M3U file
if (!lines[0] || !lines[0].startsWith('#EXTM3U')) {
throw new Error('Invalid M3U format: missing #EXTM3U header');
}
let currentInfo = null;
let currentGroup = null;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF:')) {
// Parse EXTINF line
currentInfo = parseExtinf(line);
if (currentInfo.groupTitle) {
groupsSet.add(currentInfo.groupTitle);
currentGroup = currentInfo.groupTitle;
}
} else if (line.startsWith('#EXTGRP:')) {
// Parse EXTGRP line (alternative group specification)
currentGroup = line.substring(8).trim();
groupsSet.add(currentGroup);
if (currentInfo) {
currentInfo.groupTitle = currentGroup;
}
} else if (line && !line.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: line,
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 };
}
/**
* 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;
}
/**
* 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}`);
}
const content = await response.text();
return parse(content);
}
module.exports = { parse, parseExtinf, fetchAndParse };
+161
View File
@@ -0,0 +1,161 @@
/**
* Xtream Codes API v2 Client
* Handles authentication and API calls to Xtream servers
*/
class XtreamApi {
constructor(baseUrl, username, password) {
// Clean up base URL
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.username = username;
this.password = password;
}
/**
* Build API URL with authentication
*/
buildApiUrl(action, params = {}) {
const url = new URL(`${this.baseUrl}/player_api.php`);
url.searchParams.set('username', this.username);
url.searchParams.set('password', this.password);
if (action) {
url.searchParams.set('action', action);
}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
/**
* Make API request
*/
async request(action, params = {}) {
const url = this.buildApiUrl(action, params);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Xtream API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Authenticate and get server/user info
*/
async authenticate() {
const data = await this.request(null);
if (!data.user_info) {
throw new Error('Invalid credentials or server response');
}
return data;
}
/**
* Get live channel categories
*/
async getLiveCategories() {
return this.request('get_live_categories');
}
/**
* Get live streams, optionally filtered by category
*/
async getLiveStreams(categoryId = null) {
return this.request('get_live_streams', { category_id: categoryId });
}
/**
* Get VOD categories
*/
async getVodCategories() {
return this.request('get_vod_categories');
}
/**
* Get VOD streams, optionally filtered by category
*/
async getVodStreams(categoryId = null) {
return this.request('get_vod_streams', { category_id: categoryId });
}
/**
* Get VOD info
*/
async getVodInfo(vodId) {
return this.request('get_vod_info', { vod_id: vodId });
}
/**
* Get series categories
*/
async getSeriesCategories() {
return this.request('get_series_categories');
}
/**
* Get series, optionally filtered by category
*/
async getSeries(categoryId = null) {
return this.request('get_series', { category_id: categoryId });
}
/**
* Get series info
*/
async getSeriesInfo(seriesId) {
return this.request('get_series_info', { series_id: seriesId });
}
/**
* Get short EPG for a stream
*/
async getShortEpg(streamId, limit = 10) {
return this.request('get_short_epg', { stream_id: streamId, limit });
}
/**
* Get full EPG for a stream
*/
async getSimpleDateTable(streamId) {
return this.request('get_simple_data_table', { stream_id: streamId });
}
/**
* Build stream URL for playback
*/
buildStreamUrl(streamId, type = 'live', container = 'ts') {
const typeMap = {
live: 'live',
vod: 'movie',
series: 'series'
};
const streamType = typeMap[type] || 'live';
return `${this.baseUrl}/${streamType}/${this.username}/${this.password}/${streamId}.${container}`;
}
/**
* Get XMLTV EPG URL
*/
getXmltvUrl() {
return `${this.baseUrl}/xmltv.php?username=${this.username}&password=${this.password}`;
}
}
/**
* Factory function to create API instance from source
*/
function createFromSource(source) {
return new XtreamApi(source.url, source.username, source.password);
}
/**
* Static authenticate for testing
*/
async function authenticate(url, username, password) {
const api = new XtreamApi(url, username, password);
return api.authenticate();
}
module.exports = { XtreamApi, createFromSource, authenticate };