219 Commits

Author SHA1 Message Date
Technomancer702 80d2c4bfb3 fix: copy stream url bug (#69) 2026-01-31 11:09:39 -08:00
Technomancer702 4e116d864b chore: update version number in package files 2026-01-31 11:00:43 -08:00
Technomancer702 7006513aaf fix: remove deduplication logic from channel list 2026-01-31 10:59:28 -08:00
Trevor Mears c8ca09011f fix: adjust timing for up next panel 2026-01-22 20:15:46 -08:00
Trevor Mears c042ff37b1 fix: next episode modal not displaying after resume from homepage. 2026-01-22 18:55:06 -08:00
Trevor Mears ac6c82e3fc fix: resume from homepage not correctly handling source_id 2026-01-22 18:23:48 -08:00
Trevor Mears e83b173755 fix: safari specific fixes for video player and watch page 2026-01-22 13:35:19 -08:00
Trevor Mears 89dbb96c92 fix: race condition in playNextEpisode() 2026-01-22 00:14:42 -08:00
Trevor Mears ea4a0a4577 fix: implemented safari webkit support for fullscreen and PiP 2026-01-21 20:36:34 -08:00
Trevor Mears 6f966f1095 fix: implement dynamic viewport height 2026-01-21 20:12:54 -08:00
Trevor Mears 47d582cb8c chore: update version number in package files 2026-01-21 17:03:44 -08:00
Trevor Mears 13badd249e chore: update version number in package files 2026-01-21 17:03:12 -08:00
Trevor Mears cddf02b114 fix: add safari webkit support and mobile safe area adjustments 2026-01-21 15:22:34 -08:00
Trevor Mears 3be14ef2fa chore: update version number in package files 2026-01-19 15:14:16 -08:00
Trevor Mears 3272b19177 fix: updated hardware scale filters to explicitly output 8-bit format (nv12), removed conflicting -pix_fmt flag 2026-01-19 15:13:39 -08:00
Trevor Mears 8d5e94ccca chore: update version number in package files 2026-01-19 14:59:59 -08:00
Technomancer702 7d5dbd94d6 Merge pull request #64 from technomancer702/feature/upscaling
Feature/upscaling
bug fixes
2026-01-19 14:39:39 -08:00
Trevor Mears 056555c7b4 fix: hardware transcoding 10-bit error and EPG navigation bug (Issues #61, #63) 2026-01-19 14:36:53 -08:00
Trevor Mears 059cfecdcc feat: implement 4k upscaling 2026-01-19 01:45:44 -08:00
Trevor Mears 237d11cf00 feat: implement hardware and software upscaling 2026-01-19 01:34:45 -08:00
Trevor Mears 877c6f8df9 chore: update dockerfile 2026-01-18 20:53:57 -08:00
Trevor Mears 358854ad7a chore: update dockerfile 2026-01-18 20:45:58 -08:00
Trevor Mears 4b5d6c1b78 chore: update dockerfile 2026-01-18 20:42:32 -08:00
Trevor Mears dd7d6bbf1c chore: update dockerfile 2026-01-18 20:36:42 -08:00
Trevor Mears 4061bfa2a0 chore: update dockerfile 2026-01-18 20:33:36 -08:00
Trevor Mears 995fe12396 chore: update dockerfile 2026-01-18 20:31:14 -08:00
Trevor Mears e08bcfcea7 chore: update dockerfile 2026-01-18 20:24:59 -08:00
Trevor Mears c7fb245d6b Merge feature/hw-transcoding into main 2026-01-18 20:07:37 -08:00
Trevor Mears 100a26fe4e chore: update screenshots in readme and sso icon 2026-01-18 19:49:22 -08:00
Trevor Mears 475c50b743 feat: add epg preload to app initialization 2026-01-18 19:11:21 -08:00
Trevor Mears 53c577a932 Added 60 second refresh interval for epg now indicator 2026-01-18 19:00:33 -08:00
Trevor Mears 4a8be395ca fix: mobile epg programme alignment 2026-01-18 18:48:58 -08:00
Trevor Mears f9fefa3947 fix: filter homepage recent sections by hidden status 2026-01-18 17:06:26 -08:00
Trevor Mears b890d30253 feat: add home page with favorites, resume playback, recently added VOD 2026-01-18 14:04:40 -08:00
Trevor Mears bf111e1fca feat: add home page with resume functionality and recently added VOD 2026-01-18 03:08:08 -08:00
Trevor Mears 052178e395 feat: add now playing header indicator and enable background playback for watchpage 2026-01-18 01:17:51 -08:00
Trevor Mears d6eda66360 fix: favorites arrow navigation stays in same group, add cross-player stop on new playback 2026-01-18 00:58:57 -08:00
Trevor Mears dc029f75e5 feat: implement per-user favorites with SQLite storage 2026-01-17 23:47:09 -08:00
Trevor Mears d54428472c feat: add user management modal with SSO/Local badges and fix OIDC session serialization 2026-01-17 23:36:34 -08:00
Trevor Mears 51a61aaf5a chore: update readme 2026-01-17 22:55:34 -08:00
Trevor Mears 5752f529cb feat: implement OIDC SSO with JIT provisioning 2026-01-17 22:52:06 -08:00
Trevor Mears 82b93602c1 chore: update readme 2026-01-17 22:06:05 -08:00
Trevor Mears 8137f49c4c chore: update screenshots 2026-01-17 21:53:51 -08:00
Trevor Mears 0293e9d44b chore: update readme 2026-01-17 21:51:51 -08:00
Trevor Mears fb2a50543a fix: update videoplayer to check for tvgid ensuring overlay displays program details correctly 2026-01-17 20:59:45 -08:00
Trevor Mears c4cc8ef63c fix: added format=nv12 to scale_vaapi filter and -profile:v main to ensure encoder compatibility 2026-01-17 18:57:51 -08:00
Trevor Mears 8304e7abe1 build: simplified the dockerfile to install ffmpeg via apt 2026-01-17 18:38:15 -08:00
Trevor Mears 95591a0c84 fix: settings race condition and URL deep linking 2026-01-17 18:32:19 -08:00
Trevor Mears 26ccafa3ec build: remove fallback copy logic 2026-01-17 18:08:04 -08:00
Trevor Mears 453f93599b build: multi-stage dockerfile with linuxserver/ffmpeg for full hardware encoding support 2026-01-17 18:05:49 -08:00
Trevor Mears d85ed753ab fix: resolve 'auto' encoder to detected hardware and add VAAPI drivers to Dockerfile 2026-01-17 17:54:50 -08:00
Trevor Mears 81d5f6e61e chore: update readme 2026-01-17 17:02:54 -08:00
Trevor Mears 0c797dad7a ui: move force backend proxy to dedicated network section in settings 2026-01-17 13:43:24 -08:00
Trevor Mears 39fcb36e53 ui: move force backend proxy to dedicated network section in settings 2026-01-17 13:40:38 -08:00
Trevor Mears c1f8077438 feat: add smart audio copy and audio mix presets for configurable 5.1 downmixing 2026-01-17 13:31:13 -08:00
Trevor Mears c01c33eed3 chore: update readme 2026-01-17 13:07:13 -08:00
Trevor Mears 9919975663 Refine audio downmix to ITU standards 2026-01-17 03:31:33 -08:00
Trevor Mears de7d28d3bc chore: update version number in package files 2026-01-17 03:11:41 -08:00
Trevor Mears 2c34359b2c feat: Implement hardware-accelerated HLS transcoding with granular controls, smart stereo downmixing, and robust support for MKV and 10-bit HEVC playback. 2026-01-17 03:07:16 -08:00
Trevor Mears efcf14a545 feat: implement GPU encoder pipelines 2026-01-17 01:39:26 -08:00
Trevor Mears 77eb7bff5f feat: add dedicated transcoding settings tab 2026-01-17 01:27:27 -08:00
Trevor Mears f0c1393ce0 feat: add hardware detection service for GPU-accelerated transcoding (NVIDIA/AMD/Intel/VAAPI) 2026-01-17 01:00:37 -08:00
Trevor Mears 2a03cad213 feat: add -seekable 0 to fix 405 errors and configurable User-Agent presets for stream requests 2026-01-17 00:45:55 -08:00
Trevor Mears c1a1ac4faa chore: update version number in package files 2026-01-17 00:28:01 -08:00
Trevor Mears 7c9c80c2f1 fix: add media query for desktop sidebar expand button 2026-01-16 20:53:17 -08:00
Trevor Mears b467471590 feat: add copy stream URL menu and desktop sidebar collapse (closes #43, closes #45) 2026-01-16 18:04:24 -08:00
Trevor Mears 196b9520b7 feat: add large M3U playlist warning (50K+ channels) 2026-01-16 17:00:57 -08:00
Trevor Mears 450795818e fix: m3u sync stale purge bug (#52) 2026-01-14 17:34:24 -08:00
Trevor Mears aa0fb7b956 fix: m3u sync stale purge bug 2026-01-14 17:32:19 -08:00
Trevor Mears 0d16182cdf chore: update verson number in package files 2026-01-14 15:27:42 -08:00
Trevor Mears 12a24cef09 chore: update verson number in package files 2026-01-14 15:25:49 -08:00
Trevor Mears 60010cc734 fix: race condition where race condition where loadMovies/loadSeries was called before
hiddenCategoryIds was populated
2026-01-14 15:25:04 -08:00
Trevor Mears 0a013ec05f fix: handle null stream names during Xtream sync (#50) 2026-01-14 14:48:31 -08:00
Trevor Mears 616ec37a93 fix: align EPG program blocks with time header (#47) 2026-01-13 13:00:47 -08:00
Trevor Mears 6834849cad chore: updated readme nginx example 2026-01-12 16:50:12 -08:00
Trevor Mears 17923d8996 chore: update version number in package files 2026-01-12 14:47:09 -08:00
Trevor Mears b81ce4856e fix: store tvgId during M3U sync process for EPG matching (#44) 2026-01-12 14:26:54 -08:00
Trevor Mears c951a5998e fix: normalize epg_channel_id for M3U sources (#44) 2026-01-12 14:01:37 -08:00
Trevor Mears f2fde1ea54 chore: Updated version number in package files 2026-01-12 01:37:59 -08:00
Trevor Mears 838b540922 chore: remove deprecated M3U proxy route and API method 2026-01-12 00:23:27 -08:00
Trevor Mears c589b00e57 refactor: use Xtream API endpoints for M3U in Manage Content 2026-01-12 00:07:46 -08:00
Trevor Mears 04fcabda84 refactor: use Xtream API endpoints for M3U channel loading 2026-01-12 00:02:16 -08:00
Trevor Mears a86f9ca878 feat: add M3uXtreamAdapter service for unified API handling 2026-01-11 23:52:45 -08:00
Trevor Mears 098dc3a8ce feat: add version badge to header displaying app version from package.json 2026-01-11 23:31:34 -08:00
Trevor Mears 5f24d7e264 perf: collapse groups by default and defer channel rendering in channel list 2026-01-11 16:08:20 -08:00
Trevor Mears d96bb7c776 fix: replace spread operators with concat to handle massive M3U playlists 2026-01-11 12:48:03 -08:00
Trevor Mears aeee45869b Fixed EPG description not displaying due to property name mismatch 2026-01-10 14:10:07 -08:00
Trevor Mears 8d36a2c07e feat: add subtitle extraction and display for MKV/container streams 2026-01-10 12:35:39 -08:00
Trevor Mears 346a0519e9 feat: added smart auto-transcoding with probe-based routing and MKV/remux support 2026-01-10 00:35:42 -08:00
Trevor Mears 53a54c760c Added picture-in-picture button to video player and search filter for manage content page 2026-01-09 15:38:37 -08:00
Trevor Mears e8e6ba27d7 Implemented streaming parsers for M3U and EPG to reduce memory usage 2026-01-07 23:09:23 -08:00
Trevor Mears 67d9cb26d2 Fix proxy streaming for small files like HLS encryption keys 2026-01-07 16:38:17 -08:00
Trevor Mears ffd995c1c5 Fix Pluto TV key loading by expanding CDN domain detection 2026-01-07 14:32:40 -08:00
Trevor Mears 7184a1ba56 Added DEFAULT_MAX_AGE_HOURS constant to proxy route 2026-01-07 13:51:05 -08:00
Technomancer702 05996985f1 Merge pull request #34 from yurividal/fix/hls-stream-proxy
Fix: Restore proper HLS stream proxy with encryption key handling
2026-01-07 10:01:18 -08:00
Yuri 3e834d4f41 Fix: Restore proper HLS stream proxy with encryption key handling 2026-01-07 11:17:01 -05:00
Trevor Mears 28f16980e8 Updated subtitle detection logic, added closed caption button 2026-01-06 20:58:57 -08:00
Trevor Mears 955d68e899 Update live tv player styling to match VOD player 2026-01-06 20:43:03 -08:00
Trevor Mears 5bb0629de2 Changed transcode ffmpeg flags 2026-01-06 20:16:20 -08:00
Trevor Mears f525a6ee67 Added streaming service style watch page for movies and series playback 2026-01-06 20:01:43 -08:00
Trevor Mears 3e377da67c Updated docker-publish.yml for arm64 builds 2026-01-05 18:48:30 -08:00
Trevor Mears 2d20678e05 Merge branch 'main' of https://github.com/technomancer702/nodecast-tv 2026-01-04 17:17:40 -08:00
Trevor Mears 6557b9061f Implemented cascade deletion for removed sources 2026-01-04 17:17:27 -08:00
Technomancer702 7f9485b663 Update docker-publish.yml
Added release builds
2026-01-04 13:54:51 -08:00
Trevor Mears b119b37b87 Updated sync timer logging 2026-01-04 02:01:08 -08:00
Trevor Mears 813cd0cbe6 Removed getRefreshInterval() reference 2026-01-04 00:50:07 -08:00
Trevor Mears a00082b0ef Implemented virutal scrolling for epg, optimized database batching, moved sync timer to server 2026-01-04 00:46:51 -08:00
Trevor Mears ca87a5f143 Misc fixes for syncService, remux and proxy 2026-01-04 00:18:58 -08:00
Trevor Mears 9256883aa6 Add pre-built GHCR image to docker-compose, keep build fallback 2026-01-03 23:33:43 -08:00
Trevor Mears bd9106be49 Implemented atomic writes for json database 2026-01-03 23:29:32 -08:00
Trevor Mears dd95c6f6ed Removed +igndts and +nobuffer from FFmpeg fflags 2026-01-03 23:25:47 -08:00
Trevor Mears 8d37f22f53 Fixed typo in stream type that was preventing VOD playback 2026-01-03 22:47:08 -08:00
Trevor Mears 374a87b2fd Implemented cascading update save logic 2026-01-03 21:46:07 -08:00
Trevor Mears 1193e07017 Updated readme 2026-01-03 21:28:36 -08:00
Trevor Mears 8805ddbc13 Refactored code to use sqlite for channel, epg and vod data 2026-01-03 21:07:18 -08:00
Technomancer702 ec814be3dd Merge pull request #29 from yurividal/vod-seeking
Fix: Add HTTP Range Request Support for VOD Seeking
2026-01-03 16:19:13 -08:00
Technomancer702 d1871f4077 Merge pull request #30 from yurividal/browser-navigation-fix-clean
fix: Improve browser navigation and prevent login page in history
2026-01-03 16:18:24 -08:00
Yuri fb176412f0 fix: Improve browser navigation and prevent login page in history
- Fix back button redirecting to login page when authenticated
- Add browser history management for SPA navigation between sections
- Use window.location.replace() to prevent login page in history
- Add URL hash support for bookmarkable sections
2026-01-03 07:11:58 -05:00
Yuri b7bc03a960 fix vod seeking bar 2026-01-03 06:55:55 -05:00
Trevor Mears 55c9a796b4 Updated readme 2026-01-02 15:52:19 -08:00
Trevor Mears 1be84d3ade Improved app visual identity with new icon, logo and interface fonts 2026-01-02 15:42:22 -08:00
Trevor Mears c48f2e070e Refactored EPG refresh logic 2026-01-02 15:00:11 -08:00
Trevor Mears 02ff6f3f80 Update epg guide to correctly to use server-side epg refresh interval 2026-01-01 16:37:56 -08:00
Trevor Mears 024b847d05 Increased stream analyze duration to 0.5 seconds 2026-01-01 16:16:39 -08:00
Trevor Mears d5ebd270dc Added EPG refresh timer updateVisibleEpgInfo method to ChannelList 2026-01-01 14:20:45 -08:00
Trevor Mears 0a6ebccce2 Updated readme 2026-01-01 13:24:34 -08:00
Technomancer702 bed168c646 Merge pull request #24 from technomancer702/dev
Dev
2026-01-01 13:10:56 -08:00
Trevor Mears 096420596b Merge branch 'main' into dev 2026-01-01 13:07:34 -08:00
Yuri e1a197f2be implement authentication and authorization 2026-01-01 15:36:38 -05:00
Trevor Mears fc2f20d921 Fixed issue with safari/edge native HLS path mistakenly switching to proxy mode in some cases 2026-01-01 12:18:03 -08:00
Trevor Mears 9fb8df7255 Improve FFmpeg startup speed and stream resilience 2026-01-01 11:52:47 -08:00
Technomancer702 ec9a6bcc23 Merge pull request #22 from yurividal/dev
Implement Mobile Responsiveness
2026-01-01 11:42:13 -08:00
Yuri 46c68813ec add video controls 2026-01-01 11:07:07 -05:00
Yuri 51c2fb915c feat: implement fully responsive mobile design with channel drawer
- Add comprehensive responsive breakpoints (1024px, 768px, 640px, 480px)
- Implement slide-out channel drawer for mobile Live TV page
- Add floating 'Channels' button with overlay for mobile
- Convert navigation to hamburger menu on small screens
- Optimize all pages (Movies, Series, EPG, Settings) for mobile
- Add touch-friendly interactions and safe area insets
- Improve source action buttons layout on mobile
- Add smooth scrolling and overscroll behavior controls
- Implement proper visibility handling for mobile menus
2026-01-01 10:50:56 -05:00
Trevor Mears 218757c6c3 Replaced deprecated ffmpeg -vsync with -fps_mode 2026-01-01 00:47:55 -08:00
Trevor Mears 54cedd197b Added more aggressive timestamp handling for ad stitched streams 2026-01-01 00:45:41 -08:00
Trevor Mears 2531f1786f Improved ffmpeg error resilience for ad stitched streams 2026-01-01 00:33:10 -08:00
Trevor Mears ea3aff2f04 Updated readme 2025-12-31 17:50:57 -08:00
Trevor Mears 82a0743262 Fixed hls.js config error 2025-12-31 17:10:26 -08:00
Trevor Mears ed18b8662c Merge branch 'main' of https://github.com/technomancer702/nodecast-tv 2025-12-31 17:07:07 -08:00
Trevor Mears 2ab9b34f73 Improved audio discontinuity handling 2025-12-31 17:06:54 -08:00
Technomancer702 79da89ca40 Update issue templates 2025-12-31 16:40:34 -08:00
Trevor Mears d1bfd1b9e7 Added server-side saving for settings 2025-12-31 15:35:37 -08:00
Trevor Mears 404364b904 Added remux support for extensionless urls 2025-12-31 09:55:48 -08:00
Trevor Mears a90ac32a3b Added FFmpeg system 2025-12-31 09:52:26 -08:00
Trevor Mears bec1b74208 Remove seeking logic from play method 2025-12-31 01:07:47 -08:00
Trevor Mears 7592702137 Added network timeout retry logic for direct streams 2025-12-31 00:51:39 -08:00
Trevor Mears 4683582dd0 Added retry mechanism to proxy for transient upstream 5xx erors 2025-12-31 00:36:54 -08:00
Trevor Mears 1f4e38b37e Removed duplicate error handlers 2025-12-30 23:32:50 -08:00
Trevor Mears 6d55a81d3d Added seeking after frag parsing errors 2025-12-30 23:28:07 -08:00
Trevor Mears e70232055f Improved audio discontinuity detection 2025-12-30 23:02:23 -08:00
Technomancer702 20e4101649 Update README.md 2025-12-30 17:25:21 -08:00
Trevor Mears f3dd545eb4 Updated console error for when ts streams are detected 2025-12-30 15:53:38 -08:00
Trevor Mears 71d59947b5 Updated readme 2025-12-30 15:29:53 -08:00
Trevor Mears c037ef6862 Added experimental force remux setting to remux raw ts streams to mp4 container 2025-12-30 15:27:19 -08:00
Trevor Mears 6daa3071be Updated readme 2025-12-30 13:23:56 -08:00
Trevor Mears 264a184f52 Updated readme with middleware info 2025-12-30 13:20:38 -08:00
Trevor Mears bfb984be6c Swapped images in readme 2025-12-30 11:39:29 -08:00
Trevor Mears b5c8581318 Updated readme with new screenshots 2025-12-30 11:27:38 -08:00
Trevor Mears d63c9c37d7 Refactored server/db.js and all routes to use asynchronous fs operations, refactored m3uParser and epgParser to use streaming (via readline and sax) to prevent errors with large files, updated server/routes/proxy.js to use native stream piping for images and video, reducing latency and memory overhead 2025-12-30 00:34:55 -08:00
Trevor Mears 3b0d311756 Update UI elements for consistency 2025-12-29 23:41:40 -08:00
Trevor Mears 3fffdddf4b Added proxy route for channel icons and other images, updated readme 2025-12-29 23:04:31 -08:00
Trevor Mears 3b14a17bfb Updated transcoding to support surround sound 2025-12-29 22:55:09 -08:00
Trevor Mears f8d625e9fe Merge branch 'main' of https://github.com/technomancer702/nodecast-tv 2025-12-29 22:01:09 -08:00
Trevor Mears bca9aa8c0a Add trust proxy for correct HTTPS detection behind reverse proxies 2025-12-29 22:01:00 -08:00
Technomancer702 0a94904335 Merge pull request #17 from nebula-it/main
Add GH workflow for publishing images to GHCR
2025-12-29 21:16:22 -08:00
Trevor Mears 0513016f6c Added recovery for bufferStalledError, updated readme 2025-12-29 21:06:01 -08:00
Nebula 9aabfc9b43 Add tags like latest, commit SHA and branch by default 2025-12-29 20:40:05 -08:00
Nebula 413f5e2507 Add GH workflow for publishing images to GHCR 2025-12-29 20:36:53 -08:00
Trevor Mears 59bc6d4a46 Increased discontinuity boundary seek 2025-12-29 18:33:59 -08:00
Trevor Mears dc89e8018f Updated readme 2025-12-29 18:01:59 -08:00
Technomancer702 d57d7a5fc9 Merge pull request #16 from technomancer702/feature/ffmpeg-transcoding
Feature/ffmpeg transcoding
2025-12-29 17:50:18 -08:00
Trevor Mears 83ca622efa Added HLS discontinuity detection 2025-12-29 17:45:09 -08:00
Trevor Mears 32e0434785 feat: Add FFmpeg audio transcoding for Dolby/AC3/EAC3 compatibility
- Add /api/transcode endpoint for audio-only transcoding
- Video passthrough (no re-encoding) for fast performance
- Audio transcode to AAC for browser compatibility
- Add 'Force Audio Transcode' setting toggle in UI
- Add ffmpeg-static as optional dependency
- Handle Electron asar.unpacked path for packaged apps
2025-12-29 16:52:01 -08:00
Technomancer702 61fe03f950 Merge pull request #15 from baksten/fix/channel-search-filter
Fix channel search filter for null channel names
2025-12-29 16:43:19 -08:00
baksten e4ae0e9b69 prevent null channel names from breaking search filter 2025-12-29 16:26:03 +01:00
Trevor Mears 54a4b84d6e Updated settings page icons 2025-12-29 00:14:14 -08:00
Trevor Mears 4931c08acd Updated package.json 2025-12-28 23:33:40 -08:00
Trevor Mears a216eb32bb Fixed inverted icons 2025-12-28 22:35:31 -08:00
Trevor Mears 9ef9c847fc Updated expand and collapse icons 2025-12-28 22:25:26 -08:00
Trevor Mears 29a7996ecd Implemented SVG icons 2025-12-28 21:27:21 -08:00
Trevor Mears 44365c4dad Implemented focus for active channel in channel list 2025-12-28 20:15:26 -08:00
Trevor Mears e8bfb56c8b Implemented unique renderId for channel navigation 2025-12-28 19:50:12 -08:00
Trevor Mears b504faf909 Implemented cache clearing in channel list 2025-12-28 19:35:10 -08:00
Trevor Mears 3b460591e0 Updated channel list context menu 2025-12-28 19:01:10 -08:00
Trevor Mears 3de1971383 Update readme with tvheadend settings 2025-12-28 18:10:33 -08:00
Trevor Mears 15098ad6ed Fixed hidden groups consuming batch slots 2025-12-28 17:52:40 -08:00
Trevor Mears 2ebe52685f Changed content manager group behavior and added save button 2025-12-28 16:36:58 -08:00
Trevor Mears dc15e1f396 Fixed channel ID format mismatch 2025-12-28 16:14:28 -08:00
Trevor Mears 7e5a4174d1 Fixex API call for group visibility 2025-12-28 16:07:15 -08:00
Trevor Mears 4e398bbe38 Reduce batch size for channel list, memoize EPG, virtualized source manager 2025-12-28 15:43:45 -08:00
Trevor Mears 3f4fd49023 Updated readme 2025-12-28 15:03:46 -08:00
Trevor Mears 7260f988ca Optimize proxy stream piping implementation (fixes SocketError) 2025-12-28 13:43:44 -08:00
Trevor Mears 89769dccb0 Update license to GPLv3 2025-12-28 11:58:45 -08:00
Trevor Mears 5da26c8045 Add Force Proxy setting, fix redirect URL handling (PR #7) 2025-12-28 11:43:01 -08:00
Trevor Mears 58ee98adc2 Updated readme 2025-12-28 11:17:51 -08:00
Trevor Mears 1f81323f16 Fixed placeholder.png fetch error 2025-12-28 04:01:43 -08:00
Trevor Mears 064d550cc8 Fixed HLS and MP4 handling issues 2025-12-28 03:46:59 -08:00
Trevor Mears d251488f5f Added optional chaining (?.) and nullish coalescing (??) to handle cases 2025-12-28 03:33:12 -08:00
Trevor Mears 57b0f7ba81 CORS error detection expansion, binary segment handling, EPG magic byte detection 2025-12-28 03:21:27 -08:00
Trevor Mears ebb3491005 Added non-fatal media error recovery and buffer stall recovery 2025-12-28 01:35:17 -08:00
Trevor Mears a7e0db7a4f Added two new HLS settings to help with audio issues on some streaming services 2025-12-28 01:29:27 -08:00
Trevor Mears 1a80b4c036 Removed legacy epgCache object 2025-12-28 01:24:56 -08:00
Trevor Mears ec05112f8f Enabled CEA-708 and WebVTT caption parsing 2025-12-28 01:11:14 -08:00
Trevor Mears c25da2e011 Improved HLS buffer settings to prevent audio underruns 2025-12-27 23:36:27 -08:00
Trevor Mears 4ab2128f74 Removed the .m3u8 URL requirement and prioritize HLS.js for all browsers 2025-12-27 23:28:41 -08:00
Trevor Mears 79d84aa64b Added xml.gx support for EPG 2025-12-27 23:22:37 -08:00
Trevor Mears 6a1d5b44e9 Added channel overlay to fullscreen video player 2025-12-27 23:04:34 -08:00
Trevor Mears f76e41c398 Fixed CSS issue video player to not display correctly on widescreen resolutions 2025-12-27 21:58:07 -08:00
Trevor Mears 762131f939 Fixed issue with favorites list not snycing in some circumstances 2025-12-27 21:49:22 -08:00
Trevor Mears 2028ecab08 Fixed issue with favorites list EPG not displaying properly in some configurations 2025-12-27 21:32:22 -08:00
Trevor Mears bb5dc89311 Updated cacheing to use persistent json cache 2025-12-27 21:16:17 -08:00
Trevor Mears 52a7cd53b4 Fixed issue with /hide api endpoint 2025-12-27 18:31:08 -08:00
Trevor Mears e73ed9d8b1 Update package-lock task list 2025-12-27 16:34:43 -08:00
Trevor Mears fda2cc580e Updated readme 2025-12-27 15:46:59 -08:00
Trevor Mears 52bb407fcc Updated Docker config 2025-12-27 15:42:56 -08:00
Trevor Mears 275c713296 Updated Docker config and readme 2025-12-27 14:54:13 -08:00
Trevor Mears 235b2099de Readme edits 2025-12-27 14:20:57 -08:00
Trevor Mears 4f9ec55874 Initial commit 2025-12-27 14:16:40 -08:00
41 changed files with 692 additions and 6270 deletions
-10
View File
@@ -1,10 +0,0 @@
# Copiar a .env y ajustar
PORT=3000
JWT_SECRET=cambia_esto_en_produccion
# Backend Xtream (Dispatcharr en tu red)
# Ejemplo: http://10.10.10.101:9191
# En la UI de KiraTV añades una fuente Xtream con la misma URL y usuario/contraseña de Dispatcharr.
# Opcional: si usas balanceador dedicado solo para /api/transcode, la app debe generar URLs absolutas
# con ese host (requiere cambios en front); por defecto mismo origen vía reverse proxy en 10.10.10.115
+2 -7
View File
@@ -2,11 +2,6 @@
<img src="public/img/logo-banner.png" alt="nodecast-tv" height="60" /> <img src="public/img/logo-banner.png" alt="nodecast-tv" height="60" />
</p> </p>
La aplicacion se ha modificado y personalizado.
Esta app por defecto tenía problemas a la hora de desconectarse de un canal y conectarse a otro. Se han añadido comandos para que cuando desconectamos un canal haga un "delete" y así envitar en medida de lo posible conexiones múltiples y zombies.
Se han añadido 3 Workers
# What is nodecast-tv? # What is nodecast-tv?
nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (VOD), and Series support. Built with performance and user experience in mind. nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (VOD), and Series support. Built with performance and user experience in mind.
@@ -51,7 +46,7 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/technomancer702/nodecast-tv.git git clone https://github.com/yourusername/nodecast-tv.git
cd nodecast-tv cd nodecast-tv
``` ```
@@ -119,7 +114,7 @@ Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacente
devices: devices:
- driver: nvidia - driver: nvidia
count: 1 count: 1
capabilities: [gpu, utility, video, compute] capabilities: [gpu]
``` ```
**Verify:** **Verify:**
-869
View File
@@ -1,869 +0,0 @@
diff --git a/public/index.html b/public/index.html
index dd74211..9205ef7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -9,7 +9,7 @@
<meta name="mobile-web-app-capable" content="yes">
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -1095,20 +1095,20 @@
</div>
<!-- JavaScript -->
- <script src="/js/icons.js"></script>
- <script src="/js/api.js?v=2"></script>
- <script src="/js/components/VideoPlayer.js?v=2"></script>
- <script src="/js/components/ChannelList.js?v=3"></script>
- <script src="/js/components/SourceManager.js?v=2"></script>
- <script src="/js/components/EpgGuide.js?v=2"></script>
- <script src="/js/pages/HomePage.js?v=2"></script>
- <script src="/js/pages/LivePage.js?v=2"></script>
- <script src="/js/pages/Guide.js?v=2"></script>
- <script src="/js/pages/MoviesPage.js?v=4"></script>
- <script src="/js/pages/SeriesPage.js?v=1"></script>
- <script src="/js/pages/Settings.js?v=2"></script>
- <script src="/js/pages/WatchPage.js?v=1"></script>
- <script src="/js/app.js?v=4"></script>
+ <script src="js/icons.js"></script>
+ <script src="js/api.js?v=2"></script>
+ <script src="js/components/VideoPlayer.js?v=2"></script>
+ <script src="js/components/ChannelList.js?v=3"></script>
+ <script src="js/components/SourceManager.js?v=2"></script>
+ <script src="js/components/EpgGuide.js?v=2"></script>
+ <script src="js/pages/HomePage.js?v=2"></script>
+ <script src="js/pages/LivePage.js?v=2"></script>
+ <script src="js/pages/Guide.js?v=2"></script>
+ <script src="js/pages/MoviesPage.js?v=4"></script>
+ <script src="js/pages/SeriesPage.js?v=1"></script>
+ <script src="js/pages/Settings.js?v=2"></script>
+ <script src="js/pages/WatchPage.js?v=1"></script>
+ <script src="js/app.js?v=4"></script>
<script>
// Check for SSO token in URL
(function () {
@@ -1125,4 +1125,4 @@
</script>
</body>
-</html>
\ No newline at end of file
+</html>
diff --git a/public/js/api.js b/public/js/api.js
index 77b1306..ed4b354 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -24,7 +24,7 @@ const API = {
options.body = JSON.stringify(data);
}
- const response = await fetch(`/api${endpoint}`, options);
+ const response = await fetch(`api/${endpoint}`, options);
let result;
const contentType = response.headers.get('content-type');
@@ -39,7 +39,7 @@ const API = {
// If unauthorized, redirect to login
if (response.status === 401) {
localStorage.removeItem('authToken');
- window.location.href = '/login.html';
+ window.location.href = 'login.html';
return;
}
throw new Error(result.error || `Server responded with ${response.status}`);
@@ -50,37 +50,37 @@ const API = {
// Sources
sources: {
- getAll: () => API.request('GET', '/sources'),
- getByType: (type) => API.request('GET', `/sources/type/${type}`),
- getById: (id) => API.request('GET', `/sources/${id}`),
- create: (data) => API.request('POST', '/sources', data),
- update: (id, data) => API.request('PUT', `/sources/${id}`, data),
- delete: (id) => API.request('DELETE', `/sources/${id}`),
- toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
- test: (id) => API.request('POST', `/sources/${id}/test`),
- sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync
- getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
- estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size
- estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
+ getAll: () => API.request('GET', 'sources'),
+ getByType: (type) => API.request('GET', `sources/type/${type}`),
+ getById: (id) => API.request('GET', `sources/${id}`),
+ create: (data) => API.request('POST', 'sources', data),
+ update: (id, data) => API.request('PUT', `sources/${id}`, data),
+ delete: (id) => API.request('DELETE', `sources/${id}`),
+ toggle: (id) => API.request('POST', `sources/${id}/toggle`),
+ test: (id) => API.request('POST', `sources/${id}/test`),
+ sync: (id) => API.request('POST', `sources/${id}/sync`), // Manual sync
+ getStatus: () => API.request('GET', 'sources/status'), // Get all statuses
+ estimate: (id) => API.request('GET', `sources/${id}/estimate`), // Estimate M3U size
+ estimateByUrl: (url, type) => API.request('POST', 'sources/estimate', { url, type }), // Estimate by URL (before creation)
},
// Channels (hidden items)
channels: {
- getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
- hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
- show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
- isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
- bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }),
- bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }),
+ getHidden: (sourceId = null) => API.request('GET', `channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
+ hide: (sourceId, itemType, itemId) => API.request('POST', 'channels/hide', { sourceId, itemType, itemId }),
+ show: (sourceId, itemType, itemId) => API.request('POST', 'channels/show', { sourceId, itemType, itemId }),
+ isHidden: (sourceId, itemType, itemId) => API.request('GET', `channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
+ bulkHide: (items) => API.request('POST', 'channels/hide/bulk', { items }),
+ bulkShow: (items) => API.request('POST', 'channels/show/bulk', { items }),
// Fast bulk operations - single SQL statement
- showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }),
- hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType })
+ showAll: (sourceId, contentType) => API.request('POST', 'channels/show/all', { sourceId, contentType }),
+ hideAll: (sourceId, contentType) => API.request('POST', 'channels/hide/all', { sourceId, contentType })
},
// Favorites
favorites: {
getAll: (sourceId = null, itemType = null) => {
- let url = '/favorites';
+ let url = 'favorites';
const params = [];
if (sourceId) params.push(`sourceId=${sourceId}`);
if (itemType) params.push(`itemType=${itemType}`);
@@ -88,84 +88,84 @@ const API = {
return API.request('GET', url);
},
add: (sourceId, itemId, itemType = 'channel') =>
- API.request('POST', '/favorites', { sourceId, itemId, itemType }),
+ API.request('POST', 'favorites', { sourceId, itemId, itemType }),
remove: (sourceId, itemId, itemType = 'channel') =>
- API.request('DELETE', '/favorites', { sourceId, itemId, itemType }),
+ API.request('DELETE', 'favorites', { sourceId, itemId, itemType }),
check: (sourceId, itemId, itemType = 'channel') =>
- API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
+ API.request('GET', `favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
},
// Proxy
proxy: {
// Xtream
xtream: {
- auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`),
+ auth: (sourceId) => API.request('GET', `proxy/xtream/${sourceId}/auth`),
liveCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/live_categories${params}`);
},
liveStreams: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/live_streams${query}`);
},
vodCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/vod_categories${params}`);
},
vodStreams: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/vod_streams${query}`);
},
seriesCategories: (sourceId, options = {}) => {
const params = options.includeHidden ? '?includeHidden=true' : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/series_categories${params}`);
},
series: (sourceId, categoryId = null, options = {}) => {
const params = [];
if (categoryId) params.push(`category_id=${categoryId}`);
if (options.includeHidden) params.push('includeHidden=true');
const query = params.length ? `?${params.join('&')}` : '';
- return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`);
+ return API.request('GET', `proxy/xtream/${sourceId}/series${query}`);
},
seriesInfo: (sourceId, seriesId) =>
- API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
- shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
+ API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
+ shortEpg: (sourceId, streamId) => API.request('GET', `proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
- API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
+ API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
},
// EPG
epg: {
- get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
- getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
+ get: (sourceId) => API.request('GET', `proxy/epg/${sourceId}`),
+ getForChannels: (sourceId, channelIds) => API.request('POST', `proxy/epg/${sourceId}/channels`, { channelIds })
},
// Cache management
cache: {
- clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`)
+ clear: (sourceId) => API.request('DELETE', `proxy/cache/${sourceId}`)
}
},
// Settings
settings: {
- get: () => API.request('GET', '/settings'),
- update: (data) => API.request('PUT', '/settings', data),
- reset: () => API.request('DELETE', '/settings'),
- getDefaults: () => API.request('GET', '/settings/defaults')
+ get: () => API.request('GET', 'settings'),
+ update: (data) => API.request('PUT', 'settings', data),
+ reset: () => API.request('DELETE', 'settings'),
+ getDefaults: () => API.request('GET', 'settings/defaults')
},
// Users (admin only)
users: {
- getAll: () => API.request('GET', '/auth/users'),
- create: (data) => API.request('POST', '/auth/users', data),
- update: (id, data) => API.request('PUT', `/auth/users/${id}`, data),
- delete: (id) => API.request('DELETE', `/auth/users/${id}`)
+ getAll: () => API.request('GET', 'auth/users'),
+ create: (data) => API.request('POST', 'auth/users', data),
+ update: (id, data) => API.request('PUT', `auth/users/${id}`, data),
+ delete: (id) => API.request('DELETE', `auth/users/${id}`)
}
};
diff --git a/public/js/app.js b/public/js/app.js
index c4d2f8c..f00dc38 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -169,13 +169,13 @@ class App {
if (!token) {
// No token, redirect to login (replace to avoid back button issues)
- window.location.replace('/login.html');
+ window.location.replace('login.html');
return;
}
try {
// Verify token with server
- const response = await fetch('/api/auth/me', {
+ const response = await fetch('api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -201,7 +201,7 @@ class App {
} catch (err) {
console.error('Authentication error:', err);
localStorage.removeItem('authToken');
- window.location.replace('/login.html');
+ window.location.replace('login.html');
}
}
@@ -225,7 +225,7 @@ class App {
const token = localStorage.getItem('authToken');
if (token) {
- await fetch('/api/auth/logout', {
+ await fetch('api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
@@ -234,7 +234,7 @@ class App {
}
localStorage.removeItem('authToken');
- window.location.replace('/login.html');
+ window.location.replace('login.html');
});
navbar.appendChild(logoutLink);
@@ -283,7 +283,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.app = new App();
// Fetch and display version badge
- fetch('/api/version')
+ fetch('api/version')
.then(res => res.json())
.then(data => {
const badge = document.getElementById('version-badge');
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
index e8da5ad..2047dbf 100644
--- a/public/js/components/ChannelList.js
+++ b/public/js/components/ChannelList.js
@@ -33,10 +33,10 @@ class ChannelList {
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
- if (!url || url.length === 0) return '/img/placeholder.png';
+ if (!url || url.length === 0) return 'img/placeholder.png';
// Only proxy if we're on HTTPS and the image is HTTP
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
- return `/api/proxy/image?url=${encodeURIComponent(url)}`;
+ return `api/proxy/image?url=${encodeURIComponent(url)}`;
}
return url;
}
@@ -510,7 +510,7 @@ class ChannelList {
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
@@ -623,7 +623,7 @@ class ChannelList {
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
@@ -1052,7 +1052,7 @@ class ChannelList {
div.innerHTML = `
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.getProgramInfo(channel) || ''}</div>
@@ -1305,7 +1305,7 @@ class ChannelList {
<div class="epg-info-modal">
<div class="channel-details">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" />
+ onerror="this.onerror=null;this.src='img/placeholder.png'" />
<div class="channel-meta">
<p><strong>Group:</strong> ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}</p>
<p><strong>Source:</strong> ${channel.sourceType}</p>
diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js
index ce4a01d..7b0da96 100644
--- a/public/js/components/EpgGuide.js
+++ b/public/js/components/EpgGuide.js
@@ -43,10 +43,10 @@ class EpgGuide {
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
- if (!url || url.length === 0) return '/img/placeholder.png';
+ if (!url || url.length === 0) return 'img/placeholder.png';
// Only proxy if we're on HTTPS and the image is HTTP
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
- return `/api/proxy/image?url=${encodeURIComponent(url)}`;
+ return `api/proxy/image?url=${encodeURIComponent(url)}`;
}
return url;
}
@@ -212,7 +212,7 @@ class EpgGuide {
// Load EPG from ALL sources in parallel
const fetchPromises = sources.map(async (source) => {
try {
- const response = await fetch(`/api/proxy/epg/${source.id}${queryParams}`);
+ const response = await fetch(`api/proxy/epg/${source.id}${queryParams}`);
if (!response.ok) throw new Error(`Status ${response.status}`);
return await response.json();
} catch (e) {
@@ -596,7 +596,7 @@ class EpgGuide {
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
</button>
<img class="epg-channel-logo" src="${logo}"
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
<span class="epg-channel-name">${name}</span>
<div class="resize-handle"></div>
</div>
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index a089dab..ac3b71c 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -805,7 +805,7 @@ class VideoPlayer {
async startTranscodeSession(url, options = {}) {
try {
console.log('[Player] Starting HLS transcode session...', options);
- const res = await fetch('/api/transcode/session', {
+ const res = await fetch('api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options })
@@ -817,7 +817,7 @@ class VideoPlayer {
} catch (err) {
console.error('[Player] Session start failed:', err);
// Fallback to direct transcode if session fails
- return `/api/transcode?url=${encodeURIComponent(url)}`;
+ return `api/transcode?url=${encodeURIComponent(url)}`;
}
}
@@ -829,7 +829,7 @@ class VideoPlayer {
console.log('[Player] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
+ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
@@ -865,7 +865,7 @@ class VideoPlayer {
if (this.settings.autoTranscode) {
console.log('[Player] Auto Transcode enabled. Probing stream...');
try {
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
@@ -885,7 +885,7 @@ class VideoPlayer {
track.kind = 'subtitles';
track.label = sub.title;
track.srclang = sub.language;
- track.src = `/api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`;
+ track.src = `api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`;
this.video.appendChild(track);
});
@@ -925,7 +925,7 @@ class VideoPlayer {
// Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
- const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
+ const remuxUrl = `api/remux?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = remuxUrl;
this.video.src = remuxUrl;
this.video.play().catch(e => {
@@ -997,7 +997,7 @@ class VideoPlayer {
// Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown';
try {
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
@@ -1096,7 +1096,7 @@ class VideoPlayer {
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
// Don't proxy if it's already a local API URL
- const isLocalApi = this.currentUrl.startsWith('/api/');
+ const isLocalApi = this.currentUrl.startsWith('api/');
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
console.log('CORS/Network error detected, retrying via proxy...', data.details);
@@ -1344,14 +1344,14 @@ class VideoPlayer {
* Get proxied URL for a stream
*/
getProxiedUrl(url) {
- return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
+ return `api/proxy/stream?url=${encodeURIComponent(url)}`;
}
/**
* Get transcoded URL for a stream (audio transcoding for browser compatibility)
*/
getTranscodeUrl(url) {
- return `/api/transcode?url=${encodeURIComponent(url)}`;
+ return `api/transcode?url=${encodeURIComponent(url)}`;
}
/**
@@ -1359,7 +1359,7 @@ class VideoPlayer {
* Used for raw .ts streams that browsers can't play directly
*/
getRemuxUrl(url) {
- return `/api/remux?url=${encodeURIComponent(url)}`;
+ return `api/remux?url=${encodeURIComponent(url)}`;
}
/**
diff --git a/public/js/login.js b/public/js/login.js
index a246d10..e2339f3 100644
--- a/public/js/login.js
+++ b/public/js/login.js
@@ -5,10 +5,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
const password = document.getElementById('password').value;
try {
- const response = await API.request('POST', '/auth/login', { username, password });
+ const response = await API.request('POST', 'auth/login', { username, password });
localStorage.setItem('sessionToken', response.token);
- window.location.href = '/';
+ window.location.href = 'index.html';
} catch (err) {
document.getElementById('login-error').textContent = 'Login failed: ' + err.message;
}
-});
\ No newline at end of file
+});
diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js
index 6ddadc1..c8a1805 100644
--- a/public/js/pages/HomePage.js
+++ b/public/js/pages/HomePage.js
@@ -174,7 +174,7 @@ class HomePage {
await this.renderFavoriteChannels();
// 1. Load Watch History
- const history = await window.API.request('GET', '/history?limit=12');
+ const history = await window.API.request('GET', 'history?limit=12');
if (history && Array.isArray(history)) {
this.renderHistory(history);
}
@@ -197,7 +197,7 @@ class HomePage {
try {
// Fetch favorite channels for current user
- const favorites = await window.API.request('GET', '/favorites?itemType=channel');
+ const favorites = await window.API.request('GET', 'favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
@@ -251,14 +251,14 @@ class HomePage {
}
createChannelTile(channel) {
- const logo = channel.tvgLogo || '/img/placeholder.png';
- const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
+ const logo = channel.tvgLogo || 'img/placeholder.png';
+ const logoUrl = logo.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown';
return `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
- <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
+ <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
@@ -352,7 +352,7 @@ class HomePage {
if (!list) return;
try {
- const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
+ const movies = await window.API.request('GET', 'channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
return;
@@ -381,7 +381,7 @@ class HomePage {
if (!list) return;
try {
- const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
+ const series = await window.API.request('GET', 'channels/recent?type=series&limit=12');
if (!series || series.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
return;
@@ -411,13 +411,13 @@ class HomePage {
const percent = Math.min(100, Math.round((progress / duration) * 100));
// Proxy the poster if it's an external URL
- const poster = data.poster || '/img/poster-placeholder.jpg';
- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
+ const poster = data.poster || 'img/poster-placeholder.jpg';
+ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
- <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
+ <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${percent}%"></div>
</div>
@@ -436,13 +436,13 @@ class HomePage {
createRecentCard(item) {
const { data, item_id } = item;
const type = item.type || item.item_type;
- const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg';
- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
+ const poster = item.stream_icon || data.poster || 'img/poster-placeholder.jpg';
+ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
- <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
+ <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
@@ -465,7 +465,7 @@ class HomePage {
const streamId = item.item_id;
const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4';
- const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
+ const result = await window.API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
if (result && result.url) {
const content = {
@@ -488,7 +488,7 @@ class HomePage {
// Fetch seriesInfo if we have a seriesId
if (content.seriesId && sourceId) {
try {
- const seriesInfo = await window.API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`);
+ const seriesInfo = await window.API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`);
if (seriesInfo) {
content.seriesInfo = seriesInfo;
}
diff --git a/public/js/pages/MoviesPage.js b/public/js/pages/MoviesPage.js
index a5bb40f..c27fba6 100644
--- a/public/js/pages/MoviesPage.js
+++ b/public/js/pages/MoviesPage.js
@@ -278,7 +278,7 @@ class MoviesPage {
card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId;
- const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
+ const poster = movie.stream_icon || movie.cover || 'img/placeholder.png';
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
@@ -287,7 +287,7 @@ class MoviesPage {
card.innerHTML = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
diff --git a/public/js/pages/SeriesPage.js b/public/js/pages/SeriesPage.js
index 6b7a929..1a25f85 100644
--- a/public/js/pages/SeriesPage.js
+++ b/public/js/pages/SeriesPage.js
@@ -288,7 +288,7 @@ class SeriesPage {
card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId;
- const poster = series.cover || '/img/placeholder.png';
+ const poster = series.cover || 'img/placeholder.png';
const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
@@ -297,7 +297,7 @@ class SeriesPage {
card.innerHTML = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
@@ -350,7 +350,7 @@ class SeriesPage {
this.detailsPanel.classList.remove('hidden');
// Set header info
- document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
+ document.getElementById('series-poster').src = series.cover || 'img/placeholder.png';
document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || '';
diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
index a47b5d7..437d7a5 100644
--- a/public/js/pages/Settings.js
+++ b/public/js/pages/Settings.js
@@ -259,7 +259,7 @@ class SettingsPage {
if (!container) return;
try {
- const response = await fetch('/api/settings/hw-info');
+ const response = await fetch('api/settings/hw-info');
if (!response.ok) throw new Error('Failed to fetch hardware info');
const hwInfo = await response.json();
@@ -603,7 +603,7 @@ class SettingsPage {
try {
// Fetch last sync time from server
- const response = await fetch('/api/settings/sync-status');
+ const response = await fetch('api/settings/sync-status');
if (!response.ok) throw new Error('Failed to fetch sync status');
const data = await response.json();
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js
index b85ca63..2aecdfd 100644
--- a/public/js/pages/WatchPage.js
+++ b/public/js/pages/WatchPage.js
@@ -329,7 +329,7 @@ class WatchPage {
async startTranscodeSession(url, options = {}) {
try {
console.log('[WatchPage] Starting HLS transcode session...', options);
- const res = await fetch('/api/transcode/session', {
+ const res = await fetch('api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -345,7 +345,7 @@ class WatchPage {
} catch (err) {
console.error('[WatchPage] Session start failed:', err);
// Fallback to direct transcode if session fails
- return `/api/transcode?url=${encodeURIComponent(url)}`;
+ return `api/transcode?url=${encodeURIComponent(url)}`;
}
}
@@ -357,7 +357,7 @@ class WatchPage {
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
+ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
@@ -437,7 +437,7 @@ class WatchPage {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
@@ -470,7 +470,7 @@ class WatchPage {
// TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
+ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -509,7 +509,7 @@ class WatchPage {
let videoCodec = 'unknown';
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
@@ -528,7 +528,7 @@ class WatchPage {
if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
+ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -540,7 +540,7 @@ class WatchPage {
// Determine if proxy is needed
const proxyRequiredDomains = ['pluto.tv'];
const needsProxy = settings.forceProxy || proxyRequiredDomains.some(domain => url.includes(domain));
- const finalUrl = needsProxy ? `/api/proxy/stream?url=${encodeURIComponent(url)}` : url;
+ const finalUrl = needsProxy ? `api/proxy/stream?url=${encodeURIComponent(url)}` : url;
console.log('[WatchPage] Playing:', { url, needsProxy, looksLikeHls });
@@ -600,9 +600,9 @@ class WatchPage {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error (only if not already proxied/transcoded)
// Note: Transcoded streams are local, so no CORS issues usually
- if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
+ if (!url.startsWith('api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
- this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
+ this.playHls(`api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
} else {
this.hls.destroy();
}
@@ -1030,7 +1030,7 @@ class WatchPage {
if (!this.content) return;
const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown
- const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg';
+ const fallback = isChannel ? 'img/placeholder.png' : 'img/poster-placeholder.jpg';
this.posterEl.onerror = () => {
this.posterEl.onerror = null;
@@ -1130,9 +1130,9 @@ class WatchPage {
this.recommendedGrid.innerHTML = movies.map(movie => `
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
- <img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
+ <img src="${movie.stream_icon || movie.cover || 'img/placeholder.png'}"
alt="${movie.name}"
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
<p>${movie.name}</p>
</div>
`).join('');
@@ -1417,7 +1417,7 @@ class WatchPage {
currentEpisode: this.currentEpisode || null
};
- await window.API.request('POST', '/history', {
+ await window.API.request('POST', 'history', {
id: this.content.id,
type: this.content.type === 'movie' ? 'movie' : 'episode',
sourceId: this.content.sourceId,
diff --git a/public/login.html b/public/login.html
index 454acfe..34bcb46 100644
--- a/public/login.html
+++ b/public/login.html
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NodeCast TV</title>
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
@@ -228,7 +228,7 @@
<script>
// Start SSO Login
document.getElementById('btn-sso-login').addEventListener('click', () => {
- window.location.href = '/api/auth/oidc/login';
+ window.location.href = 'api/auth/oidc/login';
});
// Check for URL Error params
@@ -247,7 +247,7 @@
async function checkSetupRequired() {
try {
- const response = await fetch('/api/auth/setup-required');
+ const response = await fetch('api/auth/setup-required');
const data = await response.json();
if (data.setupRequired) {
@@ -283,7 +283,7 @@
submitBtn.textContent = 'Please wait...';
try {
- const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
+ const endpoint = isSetupMode ? 'api/auth/setup' : 'api/auth/login';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
@@ -302,7 +302,7 @@
localStorage.setItem('authToken', data.token);
// Redirect to main app (replace history to prevent back button issues)
- window.location.replace('/');
+ window.location.replace('index.html');
} catch (err) {
errorMessage.textContent = err.message;
@@ -317,4 +317,4 @@
</script>
</body>
-</html>
\ No newline at end of file
+</html>
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "kiratv", "name": "nodecast-tv",
"version": "2.1.4-kira1", "version": "2.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "kiratv", "name": "nodecast-tv",
"version": "2.1.4-kira1", "version": "2.1.4",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2", "@ffprobe-installer/ffprobe": "^2.1.2",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "kiratv", "name": "nodecast-tv",
"version": "2.1.4-kira1", "version": "2.1.4",
"description": "KiraTV — reproductor IPTV para el navegador (fork de NodeCast)", "description": "Modern IPTV Player",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
+42 -109
View File
@@ -1,5 +1,5 @@
/* ===================================================== /* =====================================================
* KiraTV — tema oscuro (azul marino + oro) * NodeCast TV - Dark Theme Design System
===================================================== */ ===================================================== */
/* CSS Variables */ /* CSS Variables */
@@ -7,37 +7,32 @@
/* iOS Safari toolbar compensation (set dynamically by JS) */ /* iOS Safari toolbar compensation (set dynamically by JS) */
--ios-ui-bottom: 0px; --ios-ui-bottom: 0px;
/* Brand — logo hero (marino #000066 / blanco; acento arena para botones) */
--color-brand-navy: #000066;
--color-brand-navy-mid: #050045;
--color-accent: #e8d5b5;
--color-accent-hover: #f5ead8;
--color-accent-muted: rgba(232, 213, 181, 0.14);
--color-on-accent: #000044;
/* Colors */ /* Colors */
--color-bg-primary: #000022; --color-bg-primary: #0a0a0f;
--color-bg-secondary: #050038; --color-bg-secondary: #12121a;
--color-bg-tertiary: #0a0a4d; --color-bg-tertiary: #1a1a25;
--color-bg-hover: #12125c; --color-bg-hover: #22222f;
--color-bg-active: #1a1a72; --color-bg-active: #2a2a3a;
--color-accent-dim: var(--color-accent-muted); --color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-dim: rgba(99, 102, 241, 0.2);
--color-success: #34d399; --color-success: #10b981;
--color-warning: #fbbf24; --color-warning: #f59e0b;
--color-error: #f87171; --color-error: #ef4444;
--color-text-primary: #f4f6fb; --color-text-primary: #f1f1f5;
--color-text-secondary: #a8b0c4; --color-text-secondary: #a1a1aa;
--color-text-muted: #6b7389; --color-text-muted: #71717a;
--color-border: #243047; --color-border: #27272a;
--color-border-light: #344062; --color-border-light: #3f3f46;
/* Glass effect */ /* Glass effect */
--glass-bg: rgba(0, 0, 102, 0.88); --glass-bg: rgba(18, 18, 26, 0.8);
--glass-border: rgba(255, 255, 255, 0.08); --glass-border: rgba(255, 255, 255, 0.1);
/* Spacing */ /* Spacing */
--space-xs: 4px; --space-xs: 4px;
@@ -57,7 +52,7 @@
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 4px 28px rgba(232, 213, 181, 0.16); --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
/* Transitions */ /* Transitions */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
@@ -88,14 +83,11 @@
html, html,
body { body {
height: 100%; height: 100%;
font-family: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: var(--color-text-primary); color: var(--color-text-primary);
background: background: var(--color-bg-primary);
radial-gradient(ellipse 120% 85% at 50% -30%, rgba(255, 255, 255, 0.04), transparent 52%),
radial-gradient(ellipse 90% 55% at 100% 35%, rgba(0, 0, 102, 0.45), transparent),
linear-gradient(185deg, var(--color-brand-navy) 0%, var(--color-bg-primary) 55%);
overflow: hidden; overflow: hidden;
/* Prevent pull-to-refresh and overscroll bounce on mobile */ /* Prevent pull-to-refresh and overscroll bounce on mobile */
overscroll-behavior: none; overscroll-behavior: none;
@@ -145,9 +137,6 @@ h6 {
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
} }
h3 { h3 {
@@ -179,9 +168,8 @@ h4 {
padding-left: max(var(--space-lg), var(--safe-area-inset-left)); padding-left: max(var(--space-lg), var(--safe-area-inset-left));
padding-right: max(var(--space-lg), var(--safe-area-inset-right)); padding-right: max(var(--space-lg), var(--safe-area-inset-right));
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(14px); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06);
z-index: 100; z-index: 100;
} }
@@ -202,24 +190,12 @@ h4 {
fill: var(--color-accent); fill: var(--color-accent);
} }
/* Logo imagen de marca (PNG/JPEG) */
.navbar-logo-img {
display: block;
height: 42px;
width: auto;
max-width: 172px;
object-fit: contain;
object-position: left center;
border-radius: 10px;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.brand-text { .brand-text {
font-family: 'Montserrat', 'DM Sans', sans-serif; font-family: 'Century Gothic', sans-serif;
font-size: 1.45rem; font-size: 1.5rem;
font-weight: 600; font-weight: 500;
letter-spacing: -0.02em; letter-spacing: -0.03em;
color: var(--color-text-primary); color: #fff;
/* Visual correction: shift text slightly down to align optically with icon center */ /* Visual correction: shift text slightly down to align optically with icon center */
padding-top: 2px; padding-top: 2px;
} }
@@ -231,8 +207,8 @@ h4 {
.version-badge { .version-badge {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%); background: var(--color-accent);
color: var(--color-on-accent); color: #fff;
padding: 2px 6px; padding: 2px 6px;
border-radius: 10px; border-radius: 10px;
margin-left: 8px; margin-left: 8px;
@@ -1516,15 +1492,13 @@ kbd {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%); background: var(--color-accent);
color: var(--color-on-accent); color: white;
font-weight: 600;
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, var(--color-accent-hover) 0%, var(--color-accent) 100%); background: var(--color-accent-hover);
box-shadow: var(--shadow-glow); box-shadow: var(--shadow-glow);
color: var(--color-on-accent);
} }
.btn-secondary { .btn-secondary {
@@ -4441,9 +4415,9 @@ kbd {
} }
.user-badge-sso { .user-badge-sso {
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%); background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: var(--color-on-accent); color: #fff;
box-shadow: 0 2px 4px rgba(232, 213, 181, 0.22); box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
} }
.user-badge-local { .user-badge-local {
@@ -4474,24 +4448,24 @@ kbd {
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 6px 14px;
margin-right: var(--space-md); margin-right: var(--space-md);
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%); background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%);
border-radius: var(--radius-full); border-radius: var(--radius-full);
color: var(--color-on-accent); color: white;
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 200px; max-width: 200px;
box-shadow: 0 2px 10px rgba(232, 213, 181, 0.22); box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
transition: transform var(--transition-fast), box-shadow var(--transition-fast); transition: transform var(--transition-fast), box-shadow var(--transition-fast);
animation: now-playing-pulse 2s ease-in-out infinite; animation: now-playing-pulse 2s ease-in-out infinite;
} }
.now-playing-indicator:hover { .now-playing-indicator:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 4px 14px rgba(232, 213, 181, 0.28); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
} }
.now-playing-indicator.hidden { .now-playing-indicator.hidden {
@@ -4845,45 +4819,4 @@ kbd {
font-size: 0.7rem; font-size: 0.7rem;
max-width: 70px; max-width: 70px;
} }
} }
/* Electron Windows: mpv embutido (HWND) — ocultar overlays tras el vídeo nativo */
body.kiratv-win-embedded #player-controls-overlay,
body.kiratv-win-embedded #watch-overlay {
visibility: hidden !important;
}
/* WebView2 + panel vídeo Win32 — lista estrecha, reproductor fuera del motor web */
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .player-section { display: none !important; }
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .home-layout {
display: block;
}
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .channel-sidebar {
width: 100% !important;
max-width: none !important;
}
body.kiratv-shell-wv2-playing #player-controls-overlay,
body.kiratv-shell-wv2-playing #watch-overlay {
visibility: hidden !important;
}
/* App de escritorio (Electron / WebView2): ancho completo; quita cajas centradas tipo max-width:1400px */
body.kiratv-desktop-app #app {
max-width: none;
width: 100%;
margin: 0;
}
body.kiratv-desktop-app .dashboard-content {
max-width: none !important;
width: 100%;
margin-left: 0 !important;
margin-right: 0 !important;
}
body.kiratv-desktop-app .settings-container {
max-width: none !important;
}
body.kiratv-desktop-app .watch-details-section {
max-width: none !important;
}
+2 -5
View File
@@ -1,6 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6">
<rect width="64" height="64" rx="14" fill="#000066"/> <path fill="#6366f1" d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z"/>
<path fill="#ffffff" d="M14 48V14h10l14 16V14h10v34H38L24 32v16H14z"/>
<path stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" fill="none" d="M36 20c8 0 14 6 14 14s-6 14-14 14"/>
<path stroke="#ffffff" stroke-width="2" stroke-linecap="round" fill="none" opacity=".9" d="M34 16c11 0 20 9 20 20s-9 20-20 20"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

+166 -163
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -7,14 +7,14 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<title>KiraTV | Televisión en el navegador</title> <title>nodecast-tv | IPTV Player</title>
<meta name="description" content="TV en directo, películas y series con guía EPG — KiraTV"> <meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/img/kiratv-logo-hero.jpg"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/main.css?v=kira6">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Montserrat:wght@500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
rel="stylesheet">
<!-- HLS.js for video playback --> <!-- HLS.js for video playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
</head> </head>
@@ -24,10 +24,15 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <div class="navbar-brand">
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV" class="navbar-logo-img" width="176" height="44" decoding="async"> <span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6" fill="#6366f1"
class="icon">
<path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg></span>
<span class="brand-text">nodecast-tv</span>
<span id="version-badge" class="version-badge"></span> <span id="version-badge" class="version-badge"></span>
</div> </div>
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir o cerrar menú"> <button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@@ -36,14 +41,14 @@
<!-- Now Playing indicator for movies/series --> <!-- Now Playing indicator for movies/series -->
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch"> <a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
<span class="now-playing-icon">▶</span> <span class="now-playing-icon">▶</span>
<span class="now-playing-text" id="now-playing-text">Reproduciendo</span> <span class="now-playing-text" id="now-playing-text">Now Playing</span>
</a> </a>
<a href="#" class="nav-link active" data-page="home"> <a href="#" class="nav-link active" data-page="home">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon"> class="icon">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg></span> </svg></span>
<span>Inicio</span> <span>Home</span>
</a> </a>
<a href="#" class="nav-link" data-page="live"> <a href="#" class="nav-link" data-page="live">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -51,14 +56,14 @@
<path <path
d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" /> d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" />
</svg></span> </svg></span>
<span>TV en directo</span> <span>Live TV</span>
</a> </a>
<a href="#" class="nav-link" data-page="guide"> <a href="#" class="nav-link" data-page="guide">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon"> class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" /> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg></span> </svg></span>
<span>Guía TV</span> <span>TV Guide</span>
</a> </a>
<a href="#" class="nav-link" data-page="movies"> <a href="#" class="nav-link" data-page="movies">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -66,7 +71,7 @@
<path <path
d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z" /> d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z" />
</svg></span> </svg></span>
<span>Películas</span> <span>Movies</span>
</a> </a>
<a href="#" class="nav-link" data-page="series"> <a href="#" class="nav-link" data-page="series">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -82,7 +87,7 @@
<path <path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg></span> </svg></span>
<span>Ajustes</span> <span>Settings</span>
</a> </a>
</div> </div>
</nav> </nav>
@@ -92,7 +97,7 @@
<!-- Home (Dashboard) Page --> <!-- Home (Dashboard) Page -->
<div id="page-home" class="page active"> <div id="page-home" class="page active">
<!-- Content will be rendered by HomePage.js --> <!-- Content will be rendered by HomePage.js -->
<div class="loading-state">Cargando inicio…</div> <div class="loading-state">Loading Dashboard...</div>
</div> </div>
<!-- Live TV Page (formerly Home) --> <!-- Live TV Page (formerly Home) -->
@@ -103,14 +108,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" /> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg> </svg>
<span>Canales</span> <span>Channels</span>
</button> </button>
<!-- Overlay for drawer --> <!-- Overlay for drawer -->
<div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div> <div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div>
<!-- Expand button (visible when sidebar collapsed on desktop) --> <!-- Expand button (visible when sidebar collapsed on desktop) -->
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Mostrar panel de canales"> <button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Expand sidebar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" /> <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg> </svg>
@@ -121,11 +126,11 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-header-row"> <div class="sidebar-header-row">
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Buscar canales…" class="search-input"> <input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button> <button type="button" class="search-clear" title="Clear search">&times;</button>
</div> </div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn" <button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Ocultar panel"> title="Collapse sidebar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /> <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg> </svg>
@@ -133,9 +138,9 @@
</div> </div>
<div class="sidebar-controls"> <div class="sidebar-controls">
<select id="source-select" class="source-select"> <select id="source-select" class="source-select">
<option value="">Todas las fuentes</option> <option value="">All Sources</option>
</select> </select>
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expandir o plegar todo"> <button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expand/Collapse All">
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <path
d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" /> d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" />
@@ -146,8 +151,8 @@
</div> </div>
<div id="channel-list" class="channel-list"> <div id="channel-list" class="channel-list">
<div class="empty-state"> <div class="empty-state">
<p>Cargando canales…</p> <p>No channels loaded</p>
<p class="hint">Las categorías vienen plegadas: pulsa un nombre de grupo para ver los canales. Puedes usar «Expandir/plegar todo» arriba.</p> <p class="hint">Add a source in Settings to get started</p>
</div> </div>
</div> </div>
</aside> </aside>
@@ -220,9 +225,9 @@
</svg> </svg>
</button> </button>
<div class="watch-captions-menu hidden" id="player-captions-menu"> <div class="watch-captions-menu hidden" id="player-captions-menu">
<div class="captions-menu-title">Subtítulos</div> <div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-list" id="player-captions-list"> <div class="captions-menu-list" id="player-captions-list">
<button class="captions-option active" data-index="-1">Desactivados</button> <button class="captions-option active" data-index="-1">Off</button>
</div> </div>
</div> </div>
</div> </div>
@@ -252,7 +257,7 @@
<path <path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" /> d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg> </svg>
Copiar URL del stream Copy Stream URL
</button> </button>
</div> </div>
</div> </div>
@@ -262,18 +267,18 @@
<div class="player-overlay" id="player-overlay"> <div class="player-overlay" id="player-overlay">
<div class="overlay-content"> <div class="overlay-content">
<p>Elige un canal para empezar</p> <p>Select a channel to start watching</p>
</div> </div>
</div> </div>
<!-- Now Playing Overlay --> <!-- Now Playing Overlay -->
<div class="now-playing-overlay hidden" id="now-playing"> <div class="now-playing-overlay hidden" id="now-playing">
<div class="now-playing-info"> <div class="now-playing-info">
<h3 class="channel-name">Ningún canal seleccionado</h3> <h3 class="channel-name">No channel selected</h3>
<p class="program-title"></p> <p class="program-title"></p>
<p class="program-time"></p> <p class="program-time"></p>
</div> </div>
<div class="up-next"> <div class="up-next">
<h4>A continuación</h4> <h4>Up Next</h4>
<ul id="up-next-list"></ul> <ul id="up-next-list"></ul>
</div> </div>
</div> </div>
@@ -285,24 +290,24 @@
<!-- Guide Page --> <!-- Guide Page -->
<div id="page-guide" class="page"> <div id="page-guide" class="page">
<div class="guide-header"> <div class="guide-header">
<h2>Guía TV</h2> <h2>TV Guide</h2>
<div class="guide-controls"> <div class="guide-controls">
<select id="epg-group-select" class="source-select"> <select id="epg-group-select" class="source-select">
<option value="">Todos los grupos</option> <option value="">All Groups</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="epg-search" placeholder="Buscar canales…" class="search-input"> <input type="text" id="epg-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button> <button type="button" class="search-clear" title="Clear search">&times;</button>
</div> </div>
<button id="guide-prev" class="btn btn-ghost">&larr; Antes</button> <button id="guide-prev" class="btn btn-ghost">&larr; Earlier</button>
<span id="guide-date" class="guide-date">Hoy</span> <span id="guide-date" class="guide-date">Today</span>
<button id="guide-next" class="btn btn-ghost">Después &rarr;</button> <button id="guide-next" class="btn btn-ghost">Later &rarr;</button>
</div> </div>
</div> </div>
<div id="epg-grid" class="epg-grid"> <div id="epg-grid" class="epg-grid">
<div class="empty-state"> <div class="empty-state">
<p>No hay datos de guía (EPG)</p> <p>No EPG data available</p>
<p class="hint">Añade una fuente EPG en Ajustes</p> <p class="hint">Add an EPG source in Settings</p>
</div> </div>
</div> </div>
</div> </div>
@@ -310,29 +315,29 @@
<!-- Movies Page --> <!-- Movies Page -->
<div id="page-movies" class="page"> <div id="page-movies" class="page">
<div class="movies-header"> <div class="movies-header">
<h2>Películas</h2> <h2>Movies</h2>
<div class="movies-controls"> <div class="movies-controls">
<select id="movies-source-select" class="source-select"> <select id="movies-source-select" class="source-select">
<option value="">Todas las fuentes</option> <option value="">All Sources</option>
</select> </select>
<select id="movies-category-select" class="source-select"> <select id="movies-category-select" class="source-select">
<option value="">Todas las categorías</option> <option value="">All Categories</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="movies-search" placeholder="Buscar películas…" class="search-input"> <input type="text" id="movies-search" placeholder="Search movies..." class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button> <button type="button" class="search-clear" title="Clear search">&times;</button>
</div> </div>
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos"> <button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favoritos </svg> Favorites
</button> </button>
</div> </div>
</div> </div>
<div id="movies-grid" class="movies-grid"> <div id="movies-grid" class="movies-grid">
<div class="empty-state"> <div class="empty-state">
<p>Cargando películas…</p> <p>Loading movies...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -343,20 +348,20 @@
<h2>Series</h2> <h2>Series</h2>
<div class="series-controls"> <div class="series-controls">
<select id="series-source-select" class="source-select"> <select id="series-source-select" class="source-select">
<option value="">Todas las fuentes</option> <option value="">All Sources</option>
</select> </select>
<select id="series-category-select" class="source-select"> <select id="series-category-select" class="source-select">
<option value="">Todas las categorías</option> <option value="">All Categories</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="series-search" placeholder="Buscar series…" class="search-input"> <input type="text" id="series-search" placeholder="Search series..." class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button> <button type="button" class="search-clear" title="Clear search">&times;</button>
</div> </div>
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos"> <button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favoritos </svg> Favorites
</button> </button>
</div> </div>
</div> </div>
@@ -364,12 +369,12 @@
<div class="series-content"> <div class="series-content">
<div id="series-grid" class="series-grid"> <div id="series-grid" class="series-grid">
<div class="empty-state"> <div class="empty-state">
<p>Cargando series…</p> <p>Loading series...</p>
</div> </div>
</div> </div>
<!-- Series details panel (seasons/episodes) --> <!-- Series details panel (seasons/episodes) -->
<div id="series-details" class="series-details hidden"> <div id="series-details" class="series-details hidden">
<button class="series-back-btn">← Volver a series</button> <button class="series-back-btn">← Back to Series</button>
<div class="series-details-header"> <div class="series-details-header">
<img id="series-poster" src="" alt="" class="series-poster"> <img id="series-poster" src="" alt="" class="series-poster">
<div class="series-info"> <div class="series-info">
@@ -385,59 +390,59 @@
<!-- Settings Page --> <!-- Settings Page -->
<div id="page-settings" class="page"> <div id="page-settings" class="page">
<div class="settings-container"> <div class="settings-container">
<h2>Ajustes</h2> <h2>Settings</h2>
<!-- Tabs --> <!-- Tabs -->
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="sources">Fuentes</button> <button class="tab active" data-tab="sources">Sources</button>
<button class="tab" data-tab="player">Reproductor</button> <button class="tab" data-tab="player">Player</button>
<button class="tab" data-tab="transcode">Transcodificación</button> <button class="tab" data-tab="transcode">Transcoding</button>
<button class="tab" data-tab="content">Contenido</button> <button class="tab" data-tab="content">Manage Content</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Usuarios</button> <button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
</div> </div>
<!-- Sources Tab --> <!-- Sources Tab -->
<div id="tab-sources" class="tab-content active"> <div id="tab-sources" class="tab-content active">
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>Conexiones Xtream</h3> <h3>Xtream Connections</h3>
<button class="btn btn-primary" id="add-xtream">+ Añadir Xtream</button> <button class="btn btn-primary" id="add-xtream">+ Add Xtream</button>
</div> </div>
<div id="xtream-list" class="source-list"></div> <div id="xtream-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>Listas M3U</h3> <h3>M3U Playlists</h3>
<button class="btn btn-primary" id="add-m3u">+ Añadir M3U</button> <button class="btn btn-primary" id="add-m3u">+ Add M3U</button>
</div> </div>
<div id="m3u-list" class="source-list"></div> <div id="m3u-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>Fuentes EPG</h3> <h3>EPG Sources</h3>
<button class="btn btn-primary" id="add-epg">+ Añadir EPG</button> <button class="btn btn-primary" id="add-epg">+ Add EPG</button>
</div> </div>
<div id="epg-list" class="source-list"></div> <div id="epg-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<h3>Datos EPG</h3> <h3>EPG Data Settings</h3>
<div class="setting-item"> <div class="setting-item">
<label for="epg-refresh-interval">Actualizar datos EPG automáticamente cada:</label> <label for="epg-refresh-interval">Auto-refresh EPG data every:</label>
<select id="epg-refresh-interval" class="form-input"> <select id="epg-refresh-interval" class="form-input">
<option value="1">1 hora</option> <option value="1">1 hour</option>
<option value="2">2 horas</option> <option value="2">2 hours</option>
<option value="4">4 horas</option> <option value="4">4 hours</option>
<option value="12">12 horas</option> <option value="12">12 hours</option>
<option value="24" selected>24 horas</option> <option value="24" selected>24 hours</option>
<option value="0">Solo manual</option> <option value="0">Manual only</option>
</select> </select>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<span class="setting-label">Última actualización:</span> <span class="setting-label">Last Refreshed:</span>
<span id="epg-last-refreshed" class="hint">Nunca</span> <span id="epg-last-refreshed" class="hint">Never</span>
</div> </div>
<p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon" style="width: 1em; height: 1em; vertical-align: middle;"> class="icon" style="width: 1em; height: 1em; vertical-align: middle;">
@@ -450,32 +455,32 @@
<!-- Player Tab --> <!-- Player Tab -->
<div id="tab-player" class="tab-content"> <div id="tab-player" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Teclado</h3> <h3>Keyboard Controls</h3>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-arrow-keys" checked> <input type="checkbox" id="setting-arrow-keys" checked>
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Las flechas cambian de canal</span> <span class="setting-label">Arrow keys change channels</span>
<span class="setting-hint">Si está activo: ↑/↓ cambian canal y ←/→ ajustan volumen</span> <span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Overlay «Reproduciendo»</h3> <h3>Now Playing Overlay</h3>
<div class="setting-item"> <div class="setting-item">
<label for="setting-overlay-duration">Duración en pantalla (segundos)</label> <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" <input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
value="5"> value="5">
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Volumen</h3> <h3>Volume Settings</h3>
<div class="setting-item"> <div class="setting-item">
<label for="setting-default-volume">Volumen por defecto</label> <label for="setting-default-volume">Default Volume</label>
<div class="volume-slider-container"> <div class="volume-slider-container">
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80"> <input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span> <span id="volume-value">80%</span>
@@ -487,36 +492,36 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Recordar último volumen</span> <span class="setting-label">Remember Last Volume</span>
<span class="setting-hint">Si está activo, el volumen se conserva entre sesiones</span> <span class="setting-hint">When enabled, volume persists between sessions</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Reproducción</h3> <h3>Playback</h3>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-autoplay-next"> <input type="checkbox" id="setting-autoplay-next">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Autoplay del siguiente episodio</span> <span class="setting-label">Auto-play Next Episode</span>
<span class="setting-hint">Cuando termine un episodio, pasa automáticamente al siguiente</span> <span class="setting-hint">Automatically play the next episode when one ends</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Atajos de teclado</h3> <h3>Keyboard Shortcuts</h3>
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Canal anterior/siguiente (o volumen)</div> <div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Bajar/subir volumen (si flechas = canales)</div> <div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Espacio</kbd> / <kbd>K</kbd> Reproducir / Pausa</div> <div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Pantalla completa</div> <div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Silenciar / activar sonido</div> <div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Mostrar u ocultar información</div> <div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>Re Pág</kbd> / <kbd>Av Pág</kbd> Canal anterior/siguiente</div> <div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
</div> </div>
</div> </div>
</div> </div>
@@ -524,14 +529,14 @@
<!-- Transcoding Tab --> <!-- Transcoding Tab -->
<div id="tab-transcode" class="tab-content"> <div id="tab-transcode" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Hardware detectado</h3> <h3>Detected Hardware</h3>
<div id="hw-info-container" class="hw-info-container"> <div id="hw-info-container" class="hw-info-container">
<p class="hint">Cargando información del hardware…</p> <p class="hint">Loading hardware information...</p>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Codificador</h3> <h3>Encoder Settings</h3>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <div class="setting-info" style="flex: 1;">
<span class="setting-label">Hardware Encoder</span> <span class="setting-label">Hardware Encoder</span>
@@ -673,12 +678,12 @@
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <div class="setting-info" style="flex: 1;">
<span class="setting-label">Stream Output Format</span> <span class="setting-label">Stream Output Format</span>
<span class="setting-hint">Para Dispatcharr: elige <strong>TS</strong> (aunque la URL lleve .m3u8, suele <span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
ser video MPEG-TS; HLS falla en el navegador).</span> issues.</span>
</div> </div>
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;"> <select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;">
<option value="ts">MPEG-TS (ts) — recomendado con Dispatcharr</option> <option value="m3u8">HLS (m3u8) - Recommended</option>
<option value="m3u8">HLS (m3u8) — solo si el proveedor es manifiesto real</option> <option value="ts">MPEG-TS (ts) - Better buffer</option>
</select> </select>
</div> </div>
</div> </div>
@@ -729,30 +734,30 @@
<div class="content-browser"> <div class="content-browser">
<div class="content-browser-header"> <div class="content-browser-header">
<div class="content-type-toggle"> <div class="content-type-toggle">
<button class="btn btn-sm active" id="content-type-channels">Canales</button> <button class="btn btn-sm active" id="content-type-channels">Channels</button>
<button class="btn btn-sm" id="content-type-movies">Películas</button> <button class="btn btn-sm" id="content-type-movies">Movies</button>
<button class="btn btn-sm" id="content-type-series">Series</button> <button class="btn btn-sm" id="content-type-series">Series</button>
</div> </div>
<select id="content-source-select" class="source-select"> <select id="content-source-select" class="source-select">
<option value="">Selecciona una fuente…</option> <option value="">Select a source...</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="content-search" placeholder="Buscar grupos o canales…" class="search-input"> <input type="text" id="content-search" placeholder="Search groups/channels..." class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button> <button type="button" class="search-clear" title="Clear search">&times;</button>
</div> </div>
<div class="content-actions"> <div class="content-actions">
<button class="btn btn-sm btn-secondary" id="content-show-all">Mostrar todo</button> <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">Ocultar todo</button> <button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</button>
<button class="btn btn-sm btn-primary" id="content-save"><svg xmlns="http://www.w3.org/2000/svg" <button class="btn btn-sm btn-primary" id="content-save"><svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="currentColor" class="icon"> viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <path
d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z" /> d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z" />
</svg> Guardar cambios</button> </svg> Save Changes</button>
</div> </div>
</div> </div>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Marcado = visible · ☐ Sin marcar = oculto</p> <p class="hint" style="margin-bottom: var(--space-sm);">✓ Checked = Visible, ☐ Unchecked = Hidden</p>
<div id="content-tree" class="content-tree"> <div id="content-tree" class="content-tree">
<p class="hint">Selecciona una fuente para ver grupos y canales</p> <p class="hint">Select a source to view groups and channels</p>
</div> </div>
</div> </div>
</div> </div>
@@ -760,47 +765,47 @@
<!-- Users Tab (Admin Only) --> <!-- Users Tab (Admin Only) -->
<div id="tab-users" class="tab-content"> <div id="tab-users" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Usuarios</h3> <h3>User Management</h3>
<p class="hint">Gestiona cuentas y permisos</p> <p class="hint">Manage user accounts and permissions</p>
<div class="user-list-container"> <div class="user-list-container">
<table class="user-table"> <table class="user-table">
<thead> <thead>
<tr> <tr>
<th>Usuario</th> <th>Username</th>
<th>Correo</th> <th>Email</th>
<th>Rol</th> <th>Role</th>
<th>Creado</th> <th>Created</th>
<th>Acciones</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="user-list"> <tbody id="user-list">
<tr> <tr>
<td colspan="4" class="hint">Cargando usuarios…</td> <td colspan="4" class="hint">Loading users...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="add-user-section"> <div class="add-user-section">
<h4>Añadir usuario</h4> <h4>Add New User</h4>
<form id="add-user-form" class="user-form"> <form id="add-user-form" class="user-form">
<div class="form-group"> <div class="form-group">
<label for="new-username">Usuario</label> <label for="new-username">Username</label>
<input type="text" id="new-username" name="username" class="form-input" required> <input type="text" id="new-username" name="username" class="form-input" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-password">Contraseña</label> <label for="new-password">Password</label>
<input type="password" id="new-password" name="password" class="form-input" required minlength="6"> <input type="password" id="new-password" name="password" class="form-input" required minlength="6">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-role">Rol</label> <label for="new-role">Role</label>
<select id="new-role" name="role" class="form-input" required> <select id="new-role" name="role" class="form-input" required>
<option value="viewer">Espectador</option> <option value="viewer">Viewer</option>
<option value="admin">Administrador</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary">Añadir usuario</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form>
</div> </div>
</div> </div>
@@ -818,7 +823,7 @@
<div class="watch-overlay" id="watch-overlay"> <div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title --> <!-- Top Bar: Back + Title -->
<div class="watch-top-bar vod-header"> <div class="watch-top-bar vod-header">
<button class="watch-back-btn" id="watch-back-btn" title="Volver"> <button class="watch-back-btn" id="watch-back-btn" title="Back">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" /> <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg> </svg>
@@ -896,9 +901,9 @@
</svg> </svg>
</button> </button>
<div class="watch-captions-menu hidden" id="watch-captions-menu"> <div class="watch-captions-menu hidden" id="watch-captions-menu">
<div class="captions-menu-title">Subtítulos</div> <div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-list" id="watch-captions-list"> <div class="captions-menu-list" id="watch-captions-list">
<button class="captions-option active" data-index="-1">Desactivados</button> <button class="captions-option active" data-index="-1">Off</button>
</div> </div>
</div> </div>
</div> </div>
@@ -927,7 +932,7 @@
<path <path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" /> d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg> </svg>
Copiar URL del stream Copy Stream URL
</button> </button>
</div> </div>
</div> </div>
@@ -936,7 +941,7 @@
<!-- Scroll Indicator --> <!-- Scroll Indicator -->
<div class="watch-scroll-hint" id="watch-scroll-hint"> <div class="watch-scroll-hint" id="watch-scroll-hint">
<span>Desplázate para ver detalles</span> <span>Scroll for details</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" /> <path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg> </svg>
@@ -946,12 +951,12 @@
<!-- Next Episode Panel --> <!-- Next Episode Panel -->
<div class="watch-next-episode hidden" id="watch-next-episode"> <div class="watch-next-episode hidden" id="watch-next-episode">
<div class="next-info"> <div class="next-info">
<p class="next-label">Siguiente</p> <p class="next-label">Up Next</p>
<h4 id="next-episode-title"></h4> <h4 id="next-episode-title"></h4>
</div> </div>
<span class="next-countdown" id="next-countdown">10</span> <span class="next-countdown" id="next-countdown">10</span>
<button class="btn btn-primary btn-sm" id="next-play-now">Reproducir ya</button> <button class="btn btn-primary btn-sm" id="next-play-now">Play Now</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancelar</button> <button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button>
</div> </div>
</section> </section>
@@ -973,7 +978,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
<span id="watch-play-btn-text">Reproducir</span> <span id="watch-play-btn-text">Play</span>
</button> </button>
<button class="btn btn-ghost" id="watch-favorite-btn"> <button class="btn btn-ghost" id="watch-favorite-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -986,7 +991,7 @@
<path <path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> </svg>
<span>Favorito</span> <span>Favorite</span>
</button> </button>
</div> </div>
</div> </div>
@@ -994,7 +999,7 @@
<!-- Movies: Recommended Section --> <!-- Movies: Recommended Section -->
<div class="watch-recommended hidden" id="watch-recommended"> <div class="watch-recommended hidden" id="watch-recommended">
<h3>Te puede gustar</h3> <h3>More Like This</h3>
<div class="watch-recommended-grid" id="watch-recommended-grid"> <div class="watch-recommended-grid" id="watch-recommended-grid">
<!-- Movie cards inserted dynamically --> <!-- Movie cards inserted dynamically -->
</div> </div>
@@ -1002,7 +1007,7 @@
<!-- Series: Seasons & Episodes --> <!-- Series: Seasons & Episodes -->
<div class="watch-episodes hidden" id="watch-episodes"> <div class="watch-episodes hidden" id="watch-episodes">
<h3>Episodios</h3> <h3>Episodes</h3>
<div class="watch-seasons" id="watch-seasons"> <div class="watch-seasons" id="watch-seasons">
<!-- Season accordions inserted dynamically --> <!-- Season accordions inserted dynamically -->
</div> </div>
@@ -1017,7 +1022,7 @@
<div id="modal" class="modal"> <div id="modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modal-title">Ventana</h3> <h3 id="modal-title">Modal</h3>
<button class="modal-close">&times;</button> <button class="modal-close">&times;</button>
</div> </div>
<div id="modal-body" class="modal-body"></div> <div id="modal-body" class="modal-body"></div>
@@ -1030,19 +1035,19 @@
<button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" <button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon"> fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> Reproducir</button> </svg> Play</button>
<button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" <button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon"> fill="currentColor" class="icon">
<path <path
d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" /> d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
</svg> Ocultar</button> </svg> Hide</button>
</div> </div>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<div class="modal-overlay" id="edit-user-modal"> <div class="modal-overlay" id="edit-user-modal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Editar usuario</h3> <h3 class="modal-title">Edit User</h3>
<button class="modal-close" id="edit-user-close">&times;</button> <button class="modal-close" id="edit-user-close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -1050,41 +1055,41 @@
<input type="hidden" id="edit-user-id"> <input type="hidden" id="edit-user-id">
<div class="modal-form-group"> <div class="modal-form-group">
<label for="edit-username">Usuario</label> <label for="edit-username">Username</label>
<input type="text" id="edit-username" name="username" class="form-input" required> <input type="text" id="edit-username" name="username" class="form-input" required>
</div> </div>
<div class="modal-form-group"> <div class="modal-form-group">
<label for="edit-email">Correo</label> <label for="edit-email">Email</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="Sin correo asociado"> <input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated">
</div> </div>
<div class="modal-form-group"> <div class="modal-form-group">
<label for="edit-role">Rol</label> <label for="edit-role">Role</label>
<select id="edit-role" name="role" class="form-input" required> <select id="edit-role" name="role" class="form-input" required>
<option value="viewer">Espectador</option> <option value="viewer">Viewer</option>
<option value="admin">Administrador</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>
<div class="modal-form-group"> <div class="modal-form-group">
<label for="edit-password">Nueva contraseña</label> <label for="edit-password">New Password</label>
<input type="password" id="edit-password" name="password" class="form-input" <input type="password" id="edit-password" name="password" class="form-input"
placeholder="Dejar en blanco para no cambiar"> placeholder="Leave blank to keep current">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Opcional. Déjalo en blanco para mantener la actual. <p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged.
</p> </p>
</div> </div>
<div class="modal-form-group hidden" id="oidc-info-group"> <div class="modal-form-group hidden" id="oidc-info-group">
<label>ID de proveedor OIDC</label> <label>OIDC Provider ID</label>
<code id="edit-oidc-id" <code id="edit-oidc-id"
style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code> style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-ghost" id="edit-user-cancel">Cancelar</button> <button class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
<button class="btn btn-primary" id="edit-user-save">Guardar cambios</button> <button class="btn btn-primary" id="edit-user-save">Save Changes</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1092,8 +1097,7 @@
<!-- JavaScript --> <!-- JavaScript -->
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script> <script src="/js/api.js?v=2"></script>
<script src="/js/desktopPlayback.js?v=9"></script> <script src="/js/components/VideoPlayer.js?v=2"></script>
<script src="/js/components/VideoPlayer.js?v=6"></script>
<script src="/js/components/ChannelList.js?v=3"></script> <script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script> <script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script> <script src="/js/components/EpgGuide.js?v=2"></script>
@@ -1102,10 +1106,9 @@
<script src="/js/pages/Guide.js?v=2"></script> <script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script> <script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script> <script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=3"></script> <script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=3"></script> <script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/components/ChatBot.js?v=2"></script> <script src="/js/app.js?v=4"></script>
<script src="/js/app.js?v=5"></script>
<script> <script>
// Check for SSO token in URL // Check for SSO token in URL
(function () { (function () {
+1 -1
View File
@@ -136,7 +136,7 @@ const API = {
seriesInfo: (sourceId, seriesId) => seriesInfo: (sourceId, seriesId) =>
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${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}`), shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') => getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`) API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
}, },
+2 -7
View File
@@ -1,5 +1,5 @@
/** /**
* KiraTV Application Entry Point * NodeCast TV Application Entry Point
*/ */
class App { class App {
@@ -150,11 +150,6 @@ class App {
// Initialize home page first (it's needed for channel list) // Initialize home page first (it's needed for channel list)
await this.pages.home.init(); await this.pages.home.init();
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
await this.channelList.loadSources();
await this.channelList.loadChannels();
// Preload EPG data in background (non-blocking) // Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first // This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => { this.epgGuide.loadEpg().catch(err => {
@@ -166,7 +161,7 @@ class App {
const initialPage = hash && this.pages[hash] ? hash : 'home'; const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add) this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('KiraTV initialized'); console.log('NodeCast TV initialized');
} }
async checkAuth() { async checkAuth() {
+3 -3
View File
@@ -46,7 +46,7 @@ class ChannelList {
*/ */
loadCollapsedState() { loadCollapsedState() {
try { try {
const saved = localStorage.getItem('kiratv_collapsed_groups'); const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
if (saved) { if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved)); this.collapsedGroups = new Set(JSON.parse(saved));
this._hasCollapsedState = true; this._hasCollapsedState = true;
@@ -64,7 +64,7 @@ class ChannelList {
*/ */
saveCollapsedState() { saveCollapsedState() {
try { try {
localStorage.setItem('kiratv_collapsed_groups', JSON.stringify([...this.collapsedGroups])); localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
} catch (err) { } catch (err) {
console.error('Error saving collapsed state:', err); console.error('Error saving collapsed state:', err);
} }
@@ -1159,7 +1159,7 @@ class ChannelList {
let streamUrl; let streamUrl;
if (channel.sourceType === 'xtream') { if (channel.sourceType === 'xtream') {
// Get stream format from player settings (server-side) or fallback // Get stream format from player settings (server-side) or fallback
const streamFormat = window.app?.player?.settings?.streamFormat || 'ts'; const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat); const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
streamUrl = result.url; streamUrl = result.url;
} else { } else {
-428
View File
@@ -1,428 +0,0 @@
/**
* KiraBot — floating chat widget for KiraTV
*/
class ChatBot {
constructor() {
this._open = false;
this._tab = 'chat'; // 'chat' | 'recordings'
this._messages = [];
this._recordings = [];
this._loading = false;
this._token = null;
this._init();
}
_init() {
// Inject styles
const style = document.createElement('style');
style.textContent = `
#kirabot-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9000;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
border: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(124,58,237,.5);
display: flex;
align-items: center;
justify-content: center;
transition: transform .2s, box-shadow .2s;
}
#kirabot-btn:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,58,237,.7); }
#kirabot-btn svg { width: 26px; height: 26px; fill: #fff; }
#kirabot-badge {
position: absolute;
top: -4px;
right: -4px;
background: #ef4444;
color: #fff;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
min-width: 18px;
height: 18px;
display: none;
align-items: center;
justify-content: center;
padding: 0 4px;
}
#kirabot-panel {
position: fixed;
bottom: 90px;
right: 24px;
z-index: 9001;
width: 380px;
max-width: calc(100vw - 32px);
height: 560px;
max-height: calc(100vh - 110px);
background: #1a1a2e;
border: 1px solid rgba(124,58,237,.4);
border-radius: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,.6);
transition: opacity .25s, transform .25s;
overflow: hidden;
}
#kirabot-panel.hidden { opacity: 0; transform: translateY(12px) scale(.97); pointer-events: none; }
.kb-header {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.kb-header-title { font-weight: 700; font-size: 15px; color: #fff; flex: 1; }
.kb-header-sub { font-size: 11px; color: rgba(255,255,255,.7); }
.kb-close-btn {
background: rgba(255,255,255,.15);
border: none;
border-radius: 50%;
width: 28px; height: 28px;
cursor: pointer;
color: #fff;
font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.kb-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,.08);
background: #16162a;
}
.kb-tab {
flex: 1;
padding: 10px;
background: none;
border: none;
color: rgba(255,255,255,.5);
font-size: 13px;
cursor: pointer;
transition: color .2s;
}
.kb-tab.active { color: #a78bfa; border-bottom: 2px solid #7c3aed; font-weight: 600; }
.kb-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
scroll-behavior: smooth;
}
.kb-messages::-webkit-scrollbar { width: 4px; }
.kb-messages::-webkit-scrollbar-track { background: transparent; }
.kb-messages::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
.kb-bubble {
max-width: 88%;
padding: 10px 13px;
border-radius: 14px;
font-size: 13.5px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.kb-bubble.user {
align-self: flex-end;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #fff;
border-bottom-right-radius: 4px;
}
.kb-bubble.bot {
align-self: flex-start;
background: rgba(255,255,255,.07);
color: #e2e8f0;
border-bottom-left-radius: 4px;
}
.kb-bubble.error { background: rgba(239,68,68,.15); color: #fca5a5; border: 1px solid rgba(239,68,68,.3); }
.kb-typing { align-self: flex-start; color: rgba(255,255,255,.4); font-size: 13px; font-style: italic; }
.kb-input-row {
display: flex;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid rgba(255,255,255,.08);
background: #16162a;
}
.kb-input {
flex: 1;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.12);
border-radius: 12px;
color: #e2e8f0;
font-size: 13.5px;
padding: 8px 12px;
resize: none;
outline: none;
font-family: inherit;
}
.kb-input:focus { border-color: rgba(124,58,237,.6); }
.kb-send-btn {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
border: none;
border-radius: 12px;
width: 42px;
height: 42px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kb-send-btn:disabled { opacity: .4; cursor: default; }
.kb-send-btn svg { width: 18px; height: 18px; fill: #fff; }
.kb-recordings {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.kb-rec-item {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 10px 12px;
}
.kb-rec-title { font-weight: 600; font-size: 13px; color: #e2e8f0; }
.kb-rec-meta { font-size: 11px; color: rgba(255,255,255,.45); margin-top: 3px; }
.kb-rec-status { font-size: 11px; font-weight: 600; margin-top: 4px; }
.kb-rec-status.scheduled { color: #fbbf24; }
.kb-rec-status.recording { color: #f87171; }
.kb-rec-status.completed { color: #34d399; }
.kb-rec-status.failed { color: #f87171; }
.kb-rec-status.cancelled { color: rgba(255,255,255,.3); }
.kb-empty { text-align: center; color: rgba(255,255,255,.35); font-size: 13px; padding: 40px 0; }
.kb-bot-avatar { width: 22px; height: 22px; flex-shrink: 0; }
#kb-chat-view { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
#kb-rec-view { display: none; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
`;
document.head.appendChild(style);
// Floating button
const btn = document.createElement('button');
btn.id = 'kirabot-btn';
btn.title = 'KiraBot — Asistente de TV';
btn.innerHTML = `
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 11H7V9h2v2zm4 0h-2V9h2v2zm4 0h-2V9h2v2z"/></svg>
<span id="kirabot-badge"></span>
`;
btn.addEventListener('click', () => this.toggle());
document.body.appendChild(btn);
// Chat panel
const panel = document.createElement('div');
panel.id = 'kirabot-panel';
panel.className = 'hidden';
panel.innerHTML = `
<div class="kb-header">
<svg class="kb-bot-avatar" viewBox="0 0 24 24" fill="white"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>
<div>
<div class="kb-header-title">KiraBot</div>
<div class="kb-header-sub">Asistente de TV · IA</div>
</div>
<button class="kb-close-btn" id="kb-close">✕</button>
</div>
<div class="kb-tabs">
<button class="kb-tab active" id="kb-tab-chat">💬 Chat</button>
<button class="kb-tab" id="kb-tab-rec">📹 Grabaciones</button>
</div>
<div id="kb-chat-view">
<div class="kb-messages" id="kb-messages"></div>
<div class="kb-input-row">
<textarea class="kb-input" id="kb-input" rows="1"
placeholder="Pregunta por la programación, pide grabaciones..."></textarea>
<button class="kb-send-btn" id="kb-send" title="Enviar">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<div id="kb-rec-view" style="display:none;flex:1;overflow:hidden;flex-direction:column;">
<div class="kb-recordings" id="kb-recordings"></div>
</div>
`;
document.body.appendChild(panel);
// Events
document.getElementById('kb-close').addEventListener('click', () => this.close());
document.getElementById('kb-tab-chat').addEventListener('click', () => this._switchTab('chat'));
document.getElementById('kb-tab-rec').addEventListener('click', () => this._switchTab('recordings'));
document.getElementById('kb-send').addEventListener('click', () => this._sendMessage());
// Stop all keyboard and mouse events from bubbling to NodeCast's global handlers
// (NodeCast intercepts spacebar for play/pause and other keys for media control)
const inputEl = document.getElementById('kb-input');
inputEl.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); }
});
inputEl.addEventListener('keypress', (e) => e.stopPropagation());
inputEl.addEventListener('keyup', (e) => e.stopPropagation());
panel.addEventListener('click', (e) => e.stopPropagation());
panel.addEventListener('mousedown', (e) => e.stopPropagation());
// Welcome message
this._appendBotMessage('¡Hola! Soy KiraBot 👋 Puedo ayudarte con la programación de TV y programar grabaciones. ¿En qué te puedo ayudar?');
}
toggle() {
this._open ? this.close() : this.open();
}
open() {
this._open = true;
document.getElementById('kirabot-panel').classList.remove('hidden');
document.getElementById('kb-input').focus();
this._scrollToBottom();
}
close() {
this._open = false;
document.getElementById('kirabot-panel').classList.add('hidden');
}
_switchTab(tab) {
this._tab = tab;
document.getElementById('kb-tab-chat').classList.toggle('active', tab === 'chat');
document.getElementById('kb-tab-rec').classList.toggle('active', tab === 'recordings');
document.getElementById('kb-chat-view').style.display = tab === 'chat' ? 'flex' : 'none';
document.getElementById('kb-rec-view').style.display = tab === 'recordings' ? 'flex' : 'none';
if (tab === 'recordings') this._loadRecordings();
}
_appendBotMessage(text, isError = false) {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = `kb-bubble bot${isError ? ' error' : ''}`;
div.textContent = text;
msgs.appendChild(div);
this._scrollToBottom();
return div;
}
_appendUserMessage(text) {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = 'kb-bubble user';
div.textContent = text;
msgs.appendChild(div);
this._scrollToBottom();
}
_showTyping() {
const msgs = document.getElementById('kb-messages');
const div = document.createElement('div');
div.className = 'kb-typing';
div.id = 'kb-typing';
div.textContent = 'KiraBot está escribiendo...';
msgs.appendChild(div);
this._scrollToBottom();
}
_removeTyping() {
document.getElementById('kb-typing')?.remove();
}
_scrollToBottom() {
const msgs = document.getElementById('kb-messages');
if (msgs) setTimeout(() => { msgs.scrollTop = msgs.scrollHeight; }, 10);
}
async _sendMessage() {
const input = document.getElementById('kb-input');
const text = input.value.trim();
if (!text || this._loading) return;
input.value = '';
input.style.height = 'auto';
this._appendUserMessage(text);
this._loading = true;
document.getElementById('kb-send').disabled = true;
this._showTyping();
try {
const resp = await fetch('/api/kirabot/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
},
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
this._removeTyping();
if (resp.ok) {
this._appendBotMessage(data.response || 'Sin respuesta');
} else {
this._appendBotMessage(data.error || 'Error al conectar con KiraBot', true);
}
} catch (err) {
this._removeTyping();
this._appendBotMessage('No se pudo conectar con KiraBot. ¿Está el servidor activo?', true);
} finally {
this._loading = false;
document.getElementById('kb-send').disabled = false;
input.focus();
}
}
async _loadRecordings() {
const container = document.getElementById('kb-recordings');
container.innerHTML = '<div class="kb-empty">Cargando...</div>';
try {
const resp = await fetch('/api/kirabot/recordings?limit=20', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}` },
});
const recs = await resp.json();
container.innerHTML = '';
if (!Array.isArray(recs) || recs.length === 0) {
container.innerHTML = '<div class="kb-empty">No tienes grabaciones todavía.<br>¡Pídele a KiraBot que grabe algo!</div>';
return;
}
const statusLabels = {
scheduled: ['⏳', 'scheduled', 'Programada'],
recording: ['🔴', 'recording', 'Grabando'],
completed: ['✓', 'completed', 'Completada'],
failed: ['✗', 'failed', 'Fallida'],
cancelled: ['✗', 'cancelled', 'Cancelada'],
};
recs.forEach((r) => {
const [icon, cls, label] = statusLabels[r.status] || ['?', '', r.status];
const start = r.start_time ? new Date(r.start_time).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
const item = document.createElement('div');
item.className = 'kb-rec-item';
item.innerHTML = `
<div class="kb-rec-title">${this._esc(r.title)}</div>
<div class="kb-rec-meta">${this._esc(r.channel_name || '')}${start ? ' · ' + start : ''}</div>
<div class="kb-rec-status ${cls}">${icon} ${label}</div>
`;
container.appendChild(item);
});
} catch (err) {
container.innerHTML = '<div class="kb-empty" style="color:#f87171">Error al cargar las grabaciones.</div>';
}
}
_esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
// Initialize when app is ready
document.addEventListener('DOMContentLoaded', () => {
// Delay slightly to let auth check happen first
setTimeout(() => {
if (localStorage.getItem('authToken')) {
window.kiraBot = new ChatBot();
}
}, 500);
});
+17 -99
View File
@@ -8,8 +8,6 @@ class SourceManager {
this.xtreamList = document.getElementById('xtream-list'); this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list'); this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list'); this.epgList = document.getElementById('epg-list');
/** @type {Record<number, string>} id → username (admin, for labels) */
this.userNamesById = {};
// Content browser state // Content browser state
this.contentType = 'channels'; // 'channels' or 'movies' this.contentType = 'channels'; // 'channels' or 'movies'
@@ -22,29 +20,6 @@ class SourceManager {
this.init(); this.init();
} }
/**
* Comprueba si la sesión es admin. Usa /api/auth/me si window.app.currentUser
* aún no está (carrera: el modal se abre antes de que termine checkAuth).
*/
async isAdminSession() {
let role = window.app?.currentUser?.role;
if (!role && typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) {
try {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (r.ok) {
const u = await r.json();
role = u.role;
}
} catch (_) { /* ignore */ }
}
}
return String(role || '').toLowerCase() === 'admin';
}
init() { init() {
// Add source buttons // Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream')); document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
@@ -137,20 +112,9 @@ class SourceManager {
try { try {
const sources = await API.sources.getAll(); const sources = await API.sources.getAll();
const admin = await this.isAdminSession(); this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
this.userNamesById = {}; this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
if (admin) { this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
try {
const users = await API.users.getAll();
users.forEach(u => { this.userNamesById[u.id] = u.username; });
} catch (e) {
console.warn('[SourceManager] Could not load users for labels:', e);
}
}
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream', admin);
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u', admin);
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg', admin);
} catch (err) { } catch (err) {
console.error('Error loading sources:', err); console.error('Error loading sources:', err);
} }
@@ -159,7 +123,7 @@ class SourceManager {
/** /**
* Render source list * Render source list
*/ */
renderSourceList(container, sources, type, isAdmin = false) { renderSourceList(container, sources, type) {
if (sources.length === 0) { if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`; container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return; return;
@@ -167,21 +131,12 @@ class SourceManager {
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series }; const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series };
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const admin = isAdmin;
const ownerLine = (source) => admin
? `<div class="source-meta hint">${source.ownerId != null
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
: 'Fuente compartida (todos los usuarios)'}</div>`
: '';
container.innerHTML = sources.map(source => ` container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}"> <div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span> <span class="source-icon">${icons[type]}</span>
<div class="source-info"> <div class="source-info">
<div class="source-name">${esc(source.name)}</div> <div class="source-name">${source.name}</div>
<div class="source-url">${esc(source.url)}</div> <div class="source-url">${source.url}</div>
${ownerLine(source)}
</div> </div>
<div class="source-actions"> <div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button> <button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
@@ -210,7 +165,7 @@ class SourceManager {
/** /**
* Show add source modal * Show add source modal
*/ */
async showAddModal(type) { showAddModal(type) {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const title = document.getElementById('modal-title'); const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body'); const body = document.getElementById('modal-body');
@@ -219,7 +174,7 @@ class SourceManager {
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' }; const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type]; title.textContent = titles[type];
body.innerHTML = await this.getSourceForm(type); body.innerHTML = this.getSourceForm(type);
footer.innerHTML = ` footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button> <button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -247,7 +202,7 @@ class SourceManager {
const footer = document.getElementById('modal-footer'); const footer = document.getElementById('modal-footer');
title.textContent = `Edit ${type.toUpperCase()} Source`; title.textContent = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = await this.getSourceForm(type, source); body.innerHTML = this.getSourceForm(type, source);
footer.innerHTML = ` footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button> <button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -267,32 +222,11 @@ class SourceManager {
/** /**
* Get source form HTML * Get source form HTML
*/ */
async getSourceForm(type, source = {}) { getSourceForm(type, source = {}) {
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
let ownerField = '';
if (await this.isAdminSession()) {
let users = [];
try {
users = await API.users.getAll();
} catch (e) {
console.warn('[SourceManager] users list:', e);
}
const opts = [`<option value="">Compartida (todos los usuarios autenticados)</option>`].concat(
users.map(u => `<option value="${u.id}" ${Number(source.ownerId) === Number(u.id) ? 'selected' : ''}>${esc(u.username)}</option>`)
);
ownerField = `
<div class="form-group">
<label for="source-owner">Propietario</label>
<select id="source-owner" class="form-input">${opts.join('')}</select>
<p class="hint">Si asignas un usuario, solo él podrá usar esta fuente en el proxy (evita doble consumo de conexiones XC).</p>
</div>`;
}
const nameField = ` const nameField = `
<div class="form-group"> <div class="form-group">
<label for="source-name">Name</label> <label for="source-name">Name</label>
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${esc(source.name)}"> <input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
</div> </div>
`; `;
@@ -301,28 +235,27 @@ class SourceManager {
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label> <label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input" <input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}" placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${esc(source.url)}"> value="${source.url || ''}">
</div> </div>
`; `;
if (type === 'xtream') { if (type === 'xtream') {
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
return ` return `
${ownerField}
${nameField} ${nameField}
${urlField} ${urlField}
<div class="form-group"> <div class="form-group">
<label for="source-username">Username</label> <label for="source-username">Username</label>
<input type="text" id="source-username" class="form-input" value="${esc(source.username)}"> <input type="text" id="source-username" class="form-input" value="${source.username || ''}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="source-password">Password</label> <label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input" value="${pwVal}"> <input type="password" id="source-password" class="form-input"
value="${source.password && !source.password.includes('•') ? source.password : ''}">
</div> </div>
`; `;
} }
return ownerField + nameField + urlField; return nameField + urlField;
} }
/** /**
@@ -362,15 +295,7 @@ class SourceManager {
} }
} }
const payload = { type, name, url, username, password }; await API.sources.create({ type, name, url, username, password });
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
payload.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.create(payload);
document.getElementById('modal').classList.remove('active'); document.getElementById('modal').classList.remove('active');
await this.loadSources(); await this.loadSources();
@@ -404,13 +329,6 @@ class SourceManager {
data.username = username; data.username = username;
if (password) data.password = password; if (password) data.password = password;
} }
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
data.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.update(id, data); await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active'); document.getElementById('modal').classList.remove('active');
+57 -188
View File
@@ -28,9 +28,6 @@ class VideoPlayer {
this.isUsingProxy = false; this.isUsingProxy = false;
this.currentUrl = null; this.currentUrl = null;
this.settingsLoaded = false; this.settingsLoaded = false;
this.switchInProgress = false;
this.queuedSwitch = null;
this.lastSwitchAt = 0;
// Settings - start with defaults, load from server async // Settings - start with defaults, load from server async
this.settings = this.getDefaultSettings(); this.settings = this.getDefaultSettings();
@@ -56,7 +53,7 @@ class VideoPlayer {
forceTranscode: false, forceTranscode: false,
forceRemux: false, forceRemux: false,
autoTranscode: true, autoTranscode: true,
streamFormat: 'ts', streamFormat: 'm3u8',
epgRefreshInterval: '24' epgRefreshInterval: '24'
}; };
} }
@@ -68,22 +65,13 @@ class VideoPlayer {
try { try {
const serverSettings = await API.settings.get(); const serverSettings = await API.settings.get();
this.settings = { ...this.getDefaultSettings(), ...serverSettings }; this.settings = { ...this.getDefaultSettings(), ...serverSettings };
if (typeof window !== 'undefined' && window.kiratvDesktop?.isDesktopApp) {
Object.assign(this.settings, {
autoTranscode: false,
forceVideoTranscode: false,
forceTranscode: false,
forceRemux: false
});
console.log('[Player] App escritorio: sin transcodificar en servidor; reproducirás con mpv nativo.');
}
this.settingsLoaded = true; this.settingsLoaded = true;
console.log('[Player] Settings loaded from server'); console.log('[Player] Settings loaded from server');
} catch (err) { } catch (err) {
console.warn('[Player] Failed to load settings from server, using defaults:', err.message); console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
// Fall back to localStorage for backwards compatibility // Fall back to localStorage for backwards compatibility
try { try {
const saved = localStorage.getItem('kiratv_player_settings'); const saved = localStorage.getItem('nodecast_tv_player_settings');
if (saved) { if (saved) {
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) }; this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
console.log('[Player] Settings loaded from localStorage (fallback)'); console.log('[Player] Settings loaded from localStorage (fallback)');
@@ -105,7 +93,7 @@ class VideoPlayer {
console.error('[Player] Error saving settings to server:', err); console.error('[Player] Error saving settings to server:', err);
// Also save to localStorage as backup // Also save to localStorage as backup
try { try {
localStorage.setItem('kiratv_player_settings', JSON.stringify(this.settings)); localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
} catch (localErr) { } catch (localErr) {
console.error('[Player] Error saving to localStorage:', localErr); console.error('[Player] Error saving to localStorage:', localErr);
} }
@@ -218,9 +206,8 @@ class VideoPlayer {
// Play/Pause toggle // Play/Pause toggle
const togglePlay = () => { const togglePlay = () => {
if (!this.video.src) return; // sin fuente → ignorar (evita NotSupportedError)
if (this.video.paused) { if (this.video.paused) {
this.video.play().catch(() => {}); this.video.play();
} else { } else {
this.video.pause(); this.video.pause();
} }
@@ -826,58 +813,37 @@ class VideoPlayer {
* Start a HLS transcode session * Start a HLS transcode session
*/ */
async startTranscodeSession(url, options = {}) { async startTranscodeSession(url, options = {}) {
console.log('[Player] Starting HLS transcode session...', options); try {
let lastError = null; console.log('[Player] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
// Retry a couple of times to ride out provider-side 5xx/rate-limit spikes. method: 'POST',
for (let attempt = 1; attempt <= 3; attempt++) { headers: { 'Content-Type': 'application/json' },
try { body: JSON.stringify({ url, ...options })
const res = await fetch('/api/transcode/session', { });
method: 'POST', if (!res.ok) throw new Error('Failed to start session');
headers: { 'Content-Type': 'application/json' }, const session = await res.json();
body: JSON.stringify({ url, ...options }) this.currentSessionId = session.sessionId;
}); return session.playlistUrl;
if (!res.ok) { } catch (err) {
throw new Error(`Failed to start session (${res.status})`); console.error('[Player] Session start failed:', err);
} // Fallback to direct transcode if session fails
const session = await res.json(); return `/api/transcode?url=${encodeURIComponent(url)}`;
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
lastError = err;
console.warn(`[Player] Session start failed (attempt ${attempt}/3):`, err?.message || err);
if (attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 900));
}
}
} }
// Avoid fallback to /api/transcode direct stream: it tends to overlap sessions
// and breaks providers with strict single-connection limits.
throw lastError || new Error('Failed to start transcode session');
} }
/** /**
* Stop and cleanup current transcode session * Stop and cleanup current transcode session
*/ */
async stopTranscodeSession() { async stopTranscodeSession() {
if (!this.currentSessionId) return; if (this.currentSessionId) {
console.log('[Player] Stopping transcode session:', this.currentSessionId);
const sessionId = this.currentSessionId; try {
this.currentSessionId = null; // Fire and forget cleanup
console.log('[Player] Stopping transcode session:', sessionId); fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
try { } catch (err) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1500);
await fetch(`/api/transcode/${sessionId}`, {
method: 'DELETE',
signal: controller.signal
});
clearTimeout(timer);
} catch (err) {
if (err?.name !== 'AbortError') {
console.error('Failed to stop session:', err); console.error('Failed to stop session:', err);
} }
this.currentSessionId = null;
} }
} }
@@ -885,63 +851,14 @@ class VideoPlayer {
* Play a channel * Play a channel
*/ */
async play(channel, streamUrl) { async play(channel, streamUrl) {
// Avoid overlapping channel changes; keep only the latest zap request.
if (this.switchInProgress) {
this.queuedSwitch = { channel, streamUrl };
return;
}
this.switchInProgress = true;
this.currentChannel = channel; this.currentChannel = channel;
try { try {
// Stop any WatchPage playback (movies/series) before starting Live TV // Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.(); window.app?.pages?.watch?.stop?.();
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot) // Stop current playback
await this.stop(); this.stop();
if (typeof window.kiratvPlayLiveInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
this.lastSwitchAt = Date.now();
this.overlay.classList.add('hidden');
this.controlsOverlay?.classList.remove('hidden');
this.loadingSpinner?.classList.remove('show');
const absUrl = window.kiratvAbsolutePlaybackUrl
? window.kiratvAbsolutePlaybackUrl(streamUrl)
: streamUrl;
this.currentUrl = streamUrl;
console.log('[KiraTV Desktop] Lanzando mpv con URL:', absUrl);
try {
const result = await window.kiratvPlayLiveInNativePlayer(absUrl);
console.log('[KiraTV Desktop] resultado mpv:', JSON.stringify(result));
if (!result?.ok) {
console.error('[KiraTV Desktop] mpv error:', result?.error);
this.showError(
(result?.error || 'mpv no arrancó.') +
' Instala mpv (sudo apt install mpv en Linux) o coloca el binario en resources/binaries/mpv/.'
);
} else {
this.updateTranscodeStatus('direct', 'mpv nativo');
}
} catch (e) {
console.error('[KiraTV Desktop] excepción:', e);
this.showError(String(e.message || e));
}
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
}
// Give Dispatcharr enough time to release the stream slot before probing the new channel.
// 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
await new Promise(resolve => setTimeout(resolve, 2500));
this.lastSwitchAt = Date.now();
this.updateTranscodeStatus('hidden'); this.updateTranscodeStatus('hidden');
// Hide "select a channel" overlay // Hide "select a channel" overlay
@@ -1015,18 +932,15 @@ class VideoPlayer {
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return; return;
} else if (info.needsRemux) { } else if (info.needsRemux) {
// Algunos streams TS fallan en remux directo (ffmpeg mp4 mux errors). // Raw .ts container - use remux
// En ese caso es mas estable usar sesion HLS con video copy. console.log('[Player] Auto: Using remux (.ts container)');
console.log('[Player] Auto: Using HLS transcode session (copy for remux-needed stream)'); this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
this.updateTranscodeStatus('transcoding', 'Transcoding (Auto)'); const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
const playlistUrl = await this.startTranscodeSession(streamUrl, { this.currentUrl = remuxUrl;
videoMode: 'copy', this.video.src = remuxUrl;
videoCodec: info.video, this.video.play().catch(e => {
audioCodec: info.audio, if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
audioChannels: info.audioChannels
}); });
this.currentUrl = playlistUrl;
this.playHls(playlistUrl);
this.updateNowPlaying(channel); this.updateNowPlaying(channel);
this.showNowPlayingOverlay(); this.showNowPlayingOverlay();
this.fetchEpgData(channel); this.fetchEpgData(channel);
@@ -1050,7 +964,20 @@ class VideoPlayer {
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' }); const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
this.currentUrl = playlistUrl; this.currentUrl = playlistUrl;
// Load HLS
this.updateNowPlaying(channel, 'Transcoding (Video)');
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
// But for minimize drift, I'll copy the block logic for HLS playback init
// Actually, I can just fall through if I set looksLikeHls = true?
// No, play logic is sequential.
if (Hls.isSupported()) { if (Hls.isSupported()) {
// Start HLS
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
// But HLS block is lower down.
// I will just execute the HLS init here as before.
// Actually, easiest way is to re-assign streamUrl and goto start? No.
// Copy existing forceTranscode block logic
if (this.hls) { if (this.hls) {
this.hls.destroy(); this.hls.destroy();
} }
@@ -1060,6 +987,7 @@ class VideoPlayer {
this.hls.on(Hls.Events.MANIFEST_PARSED, () => { this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(console.error); this.video.play().catch(console.error);
}); });
// Handle errors
this.hls.on(Hls.Events.ERROR, (event, data) => { this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
console.log('[Player] HLS fatal error'); console.log('[Player] HLS fatal error');
@@ -1067,19 +995,8 @@ class VideoPlayer {
} }
}); });
this.updateNowPlaying(channel); return; // Exit
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
} }
console.warn('[Player] HLS.js (MSE) not available; usando HLS nativo si el navegador lo permite…');
this.playHls(playlistUrl);
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
} }
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting // CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
@@ -1151,28 +1068,13 @@ class VideoPlayer {
return; return;
} }
// Raw .ts URLs: browsers need remux (or transcode). Mirror "Force Remux" when Smart // If raw TS detected without Force Remux enabled, show error
// transcode is on so a failed/missed probe does not dead-end here with a confusing error.
if (isRawTs && !this.settings.forceRemux) { if (isRawTs && !this.settings.forceRemux) {
if (this.settings.autoTranscode) {
console.log('[Player] Raw MPEG-TS (.ts) + Auto Transcode — remux automático (servidor → fMP4)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto TS)');
const remuxUrl = this.getRemuxUrl(streamUrl);
this.video.src = remuxUrl;
this.video.play().catch((e) => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
});
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
}
console.warn('[Player] Raw MPEG-TS stream detected. Browsers cannot play .ts files directly.'); console.warn('[Player] Raw MPEG-TS stream detected. Browsers cannot play .ts files directly.');
this.showError( this.showError(
'This stream uses raw MPEG-TS format (.ts) which browsers cannot play directly.<br><br>' + 'This stream uses raw MPEG-TS format (.ts) which browsers cannot play directly.<br><br>' +
'<strong>To fix this:</strong><br>' + '<strong>To fix this:</strong><br>' +
'1. Enable <strong>"Auto Transcode (Smart)"</strong> or <strong>"Force Remux"</strong> in Settings → Streaming<br>' + '1. Enable <strong>"Force Remux"</strong> in Settings → Streaming<br>' +
'2. Or configure your source to output HLS (.m3u8) format' '2. Or configure your source to output HLS (.m3u8) format'
); );
return; return;
@@ -1281,13 +1183,6 @@ class VideoPlayer {
} catch (err) { } catch (err) {
console.error('Error playing channel:', err); console.error('Error playing channel:', err);
this.showError('Failed to play channel'); this.showError('Failed to play channel');
} finally {
this.switchInProgress = false;
if (this.queuedSwitch) {
const next = this.queuedSwitch;
this.queuedSwitch = null;
this.play(next.channel, next.streamUrl);
}
} }
} }
@@ -1297,25 +1192,6 @@ class VideoPlayer {
playHls(url) { playHls(url) {
if (this.hls) { if (this.hls) {
this.hls.destroy(); this.hls.destroy();
this.hls = null;
}
const nativeHls =
this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe';
if ((!Hls.isSupported() || typeof Hls === 'undefined') && nativeHls) {
console.log('[Player] HLS playlist via reproducción nativa (sin MSE)');
this.video.src = url;
this.video.play().catch((e) => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
});
return;
}
if (!Hls.isSupported()) {
console.error('[Player] Este navegador no soporta HLS (ni MSE ni HLS nativo); no se puede reproducir la lista');
return;
} }
this.hls = new Hls(this.getHlsConfig()); this.hls = new Hls(this.getHlsConfig());
@@ -1511,24 +1387,17 @@ class VideoPlayer {
/** /**
* Stop playback * Stop playback
*/ */
async stop() { stop() {
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
await window.kiratvDesktop.stopExternalMpv().catch(() => {});
}
// Stop any running transcode session first // Stop any running transcode session first
await this.stopTranscodeSession(); this.stopTranscodeSession();
if (this.hls) { if (this.hls) {
this.hls.destroy(); this.hls.destroy();
this.hls = null; this.hls = null;
} }
this.video.pause(); this.video.pause();
// En modo escritorio el vídeo va a mpv; limpiar src dispara NotSupportedError this.video.src = '';
if (!window.kiratvDesktop?.isDesktopApp) { this.video.load();
this.video.src = '';
this.video.load();
}
// Reset UI to idle state // Reset UI to idle state
this.overlay.classList.remove('hidden'); // Show "Select a channel" this.overlay.classList.remove('hidden'); // Show "Select a channel"
-325
View File
@@ -1,325 +0,0 @@
/**
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
*/
(function () {
function showToast(msg, type, durationMs) {
let container = document.getElementById('kiratv-desktop-toast');
if (!container) {
container = document.createElement('div');
container.id = 'kiratv-desktop-toast';
Object.assign(container.style, {
position: 'fixed',
bottom: '24px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
pointerEvents: 'none'
});
document.body.appendChild(container);
}
const toast = document.createElement('div');
Object.assign(toast.style, {
background: type === 'error' ? '#c0392b' : '#1a7f4a',
color: '#fff',
padding: '10px 20px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'sans-serif',
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
opacity: '1',
transition: 'opacity 0.4s'
});
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 450);
}, durationMs || 3500);
}
function unwrapDesktopWrapperUrl(urlLike) {
try {
const u = new URL(String(urlLike), window.location.origin);
const wrappedPath = u.pathname;
if (
wrappedPath === '/api/transcode' ||
wrappedPath === '/api/remux' ||
wrappedPath === '/api/proxy/stream'
) {
const original = u.searchParams.get('url');
if (original) return original;
}
} catch (_) {
/* */
}
return urlLike;
}
function absolutePlaybackUrl(streamUrl) {
if (!streamUrl) return '';
const raw = String(streamUrl).trim();
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
if (/^https?:\/\//i.test(s)) return s;
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
}
async function resolveNativePlaybackUrl(streamUrl) {
const abs = absolutePlaybackUrl(streamUrl);
try {
const u = new URL(abs);
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
const r = await fetch(abs, { credentials: 'include' });
if (r.ok) {
const j = await r.json();
if (j && typeof j.url === 'string' && j.url.trim()) {
return j.url.trim();
}
}
}
} catch (_) {
/* usar abs */
}
return abs;
}
function authBearerPayload() {
try {
const t = localStorage.getItem('authToken');
return t && t.length > 0 ? { authorizationBearer: t } : {};
} catch (_) {
return {};
}
}
function authBearerPayloadFor(url) {
try {
const u = new URL(url, window.location.origin);
if (u.origin !== window.location.origin) return {};
} catch (_) {
return {};
}
return authBearerPayload();
}
function elementToBounds(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.max(1, Math.round(r.width)),
height: Math.max(1, Math.round(r.height))
};
}
/**
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
*/
function getEmbedBounds() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
if (window.kiratvDesktop.embedUsesNativePanel) return null;
const watchPage = document.getElementById('page-watch');
if (watchPage && watchPage.classList.contains('active')) {
const videoEl = document.getElementById('watch-video');
if (videoEl) return elementToBounds(videoEl);
const section = document.querySelector('.watch-video-section');
if (section) return elementToBounds(section);
}
const livePage = document.getElementById('page-live');
if (livePage && livePage.classList.contains('active')) {
const ps = document.querySelector('.player-section');
if (ps) return elementToBounds(ps);
}
const ps = document.querySelector('.player-section');
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
const wv = document.getElementById('watch-video');
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
return null;
}
function embedUsesNativePanel() {
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
}
function setEmbeddedUiClass(active) {
const native = embedUsesNativePanel();
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
}
/**
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
*/
function shellChromeModeFromHash() {
const raw = location.hash || '#home';
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
if (h === 'live') return 'liveSplit';
if (h === 'watch') return 'vodSplit';
return 'fullWeb';
}
function syncShellChrome() {
const d = window.kiratvDesktop;
if (typeof d?.setShellChromeMode !== 'function') return;
const mode = shellChromeModeFromHash();
d.setShellChromeMode({ mode }).catch(() => {});
if (embedUsesNativePanel() && mode === 'fullWeb') {
d.stopExternalMpv?.().catch(() => {});
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
}
}
function initEmbedBoundsSync() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
if (embedUsesNativePanel()) return;
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
let t = null;
function flushBounds() {
clearTimeout(t);
t = setTimeout(() => {
const b = getEmbedBounds();
if (!b) return;
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
}, 72);
}
window.addEventListener('resize', flushBounds);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(flushBounds);
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
try {
ro.observe(el);
} catch (_) {
/* */
}
});
}
window.addEventListener('hashchange', flushBounds);
flushBounds();
}
function useEmbeddedPlayback() {
const d = window.kiratvDesktop;
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
}
async function playInMpv(absUrl, extraArgs) {
const d = window.kiratvDesktop;
if (!d?.playExternalMpv) {
showToast('Error: sin puente escritorio', 'error');
return { ok: false, error: 'Sin puente escritorio' };
}
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
if (useEmbeddedPlayback()) {
const native = embedUsesNativePanel();
let bounds = null;
if (!native) {
bounds = getEmbedBounds();
if (!bounds || bounds.width < 2 || bounds.height < 2) {
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
return { ok: false, error: 'bounds vídeo vacíos' };
}
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
try {
await d.setVideoHostBounds(bounds);
} catch (_) {
/* */
}
} else {
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
}
setEmbeddedUiClass(true);
const payload = {
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
};
if (!native && bounds) payload.bounds = bounds;
const result = await d.playEmbeddedMpv(payload);
if (!result?.ok) {
setEmbeddedUiClass(false);
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast('⚠ ' + msg, 'error', 6000);
}
return result;
}
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
const result = await d.playExternalMpv({
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
});
if (!result?.ok) {
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast(
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
'error',
6000
);
}
return result;
}
function whenReady(cb) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb, { once: true });
} else {
cb();
}
}
whenReady(() => {
if (window.kiratvDesktop?.isDesktopApp) {
document.body.classList.add('kiratv-desktop-app');
}
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
initEmbedBoundsSync();
}
window.addEventListener('hashchange', syncShellChrome);
setTimeout(syncShellChrome, 0);
});
window.kiratvDesktopClearEmbeddedChrome = function () {
setEmbeddedUiClass(false);
};
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
return playInMpv(absoluteUrl);
};
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
return playInMpv(absoluteUrl, extraArgs);
};
})();
+2 -4
View File
@@ -238,7 +238,7 @@ class MoviesPage {
this.container.innerHTML = ''; this.container.innerHTML = '';
if (this.filteredMovies.length === 0) { if (this.filteredMovies.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado películas</p></div>'; this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
return; return;
} }
@@ -278,8 +278,7 @@ class MoviesPage {
card.dataset.movieId = movie.stream_id; card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId; card.dataset.sourceId = movie.sourceId;
const rawPoster = movie.stream_icon || movie.cover || ''; const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
const year = movie.year || movie.releaseDate?.substring(0, 4) || ''; const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : ''; const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
@@ -352,7 +351,6 @@ class MoviesPage {
description: movie.plot || '', description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4), year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating, rating: movie.rating,
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
sourceId: movie.sourceId, sourceId: movie.sourceId,
categoryId: movie.category_id, categoryId: movie.category_id,
containerExtension: container containerExtension: container
+9 -12
View File
@@ -250,7 +250,7 @@ class SeriesPage {
this.container.innerHTML = ''; this.container.innerHTML = '';
if (this.filteredSeries.length === 0) { if (this.filteredSeries.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado series</p></div>'; this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
return; return;
} }
@@ -288,8 +288,7 @@ class SeriesPage {
card.dataset.seriesId = series.series_id; card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId; card.dataset.sourceId = series.sourceId;
const rawPoster = series.cover || ''; const poster = series.cover || '/img/placeholder.png';
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
const year = series.year || series.releaseDate?.substring(0, 4) || ''; const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : ''; const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
@@ -351,8 +350,7 @@ class SeriesPage {
this.detailsPanel.classList.remove('hidden'); this.detailsPanel.classList.remove('hidden');
// Set header info // Set header info
const rawCover = series.cover || ''; document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
document.getElementById('series-poster').src = rawCover.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawCover)}` : (rawCover || '/img/placeholder.png');
document.getElementById('series-title').textContent = series.name; document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || ''; document.getElementById('series-plot').textContent = series.plot || '';
@@ -364,7 +362,7 @@ class SeriesPage {
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id); const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
if (!info || !info.episodes) { if (!info || !info.episodes) {
this.seasonsContainer.innerHTML = '<p class="hint">No hay episodios</p>'; this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
return; return;
} }
@@ -381,13 +379,13 @@ class SeriesPage {
<div class="season-group"> <div class="season-group">
<div class="season-header"> <div class="season-header">
<span class="season-expander">${Icons.chevronDown}</span> <span class="season-expander">${Icons.chevronDown}</span>
<span class="season-name">Temporada ${seasonNum} (${episodes.length} episodios)</span> <span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
</div> </div>
<div class="episode-list"> <div class="episode-list">
${episodes.map(ep => ` ${episodes.map(ep => `
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}"> <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-number">E${ep.episode_num}</span>
<span class="episode-title">${ep.title || `Episodio ${ep.episode_num}`}</span> <span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
<span class="episode-duration">${ep.duration || ''}</span> <span class="episode-duration">${ep.duration || ''}</span>
</div> </div>
`).join('')} `).join('')}
@@ -410,7 +408,7 @@ class SeriesPage {
} catch (err) { } catch (err) {
console.error('Error loading series info:', err); console.error('Error loading series info:', err);
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error al cargar episodios</p>'; this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
} }
} }
@@ -428,7 +426,7 @@ class SeriesPage {
// Get season and episode number from the episode element context // Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group'); const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || ''; const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Temporada (\d+)/); const seasonMatch = seasonHeader.match(/Season (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1'; const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1'; const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
@@ -439,7 +437,7 @@ class SeriesPage {
if (result && result.url) { if (result && result.url) {
// Play in dedicated Watch page // Play in dedicated Watch page
if (this.app.pages.watch) { if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episodio ${episodeNum}`; const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
this.app.pages.watch.play({ this.app.pages.watch.play({
type: 'series', type: 'series',
@@ -455,7 +453,6 @@ class SeriesPage {
seriesInfo: this.currentSeriesInfo, seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum, currentSeason: seasonNum,
currentEpisode: episodeNum, currentEpisode: episodeNum,
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
containerExtension: container containerExtension: container
}, result.url); }, result.url);
} }
+17 -21
View File
@@ -130,7 +130,7 @@ class SettingsPage {
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true; if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts'; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome'; if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) { if (customUaContainer) {
@@ -325,11 +325,11 @@ class SettingsPage {
try { try {
await API.users.create({ username, password, role }); await API.users.create({ username, password, role });
alert('Usuario creado correctamente.'); alert('User created successfully!');
addUserForm.reset(); addUserForm.reset();
this.loadUsers(); this.loadUsers();
} catch (err) { } catch (err) {
alert('Error al crear usuario: ' + err.message); alert('Error creating user: ' + err.message);
} }
}); });
} }
@@ -453,7 +453,7 @@ class SettingsPage {
console.log('Modal should now be visible!'); console.log('Modal should now be visible!');
} catch (err) { } catch (err) {
console.error('Error populating modal:', err); console.error('Error populating modal:', err);
alert('Error al abrir edición: ' + err.message); alert('Error opening edit modal: ' + err.message);
} }
} }
@@ -493,7 +493,7 @@ class SettingsPage {
closeModal(); closeModal();
this.loadUsers(); this.loadUsers();
} catch (err) { } catch (err) {
alert('Error al actualizar usuario: ' + err.message); alert('Error updating user: ' + err.message);
} }
}; };
@@ -510,7 +510,7 @@ class SettingsPage {
await API.users.delete(userId); await API.users.delete(userId);
this.loadUsers(); this.loadUsers();
} catch (err) { } catch (err) {
alert('Error al eliminar usuario: ' + err.message); alert('Error deleting user: ' + err.message);
} }
} }
@@ -523,11 +523,6 @@ class SettingsPage {
this.app.sourceManager.loadContentSources(); this.app.sourceManager.loadContentSources();
} }
// Refrescar listas al volver a Fuentes (evita lista vacía tras crear/asignar)
if (tabName === 'sources') {
this.app.sourceManager.loadSources();
}
// Load users when switching to users tab // Load users when switching to users tab
if (tabName === 'users') { if (tabName === 'users') {
this.loadUsers(); this.loadUsers();
@@ -580,7 +575,7 @@ class SettingsPage {
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false; if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24'; if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts'; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
// User-Agent settings // User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent'); const userAgentSelect = document.getElementById('setting-user-agent');
@@ -623,25 +618,26 @@ class SettingsPage {
let text; let text;
if (diffMins < 1) { if (diffMins < 1) {
text = 'Hace un momento'; text = 'Just now';
} else if (diffMins < 60) { } else if (diffMins < 60) {
text = `Hace ${diffMins} min`; text = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
} else if (diffHours < 24) { } else if (diffHours < 24) {
text = `Hace ${diffHours} h`; text = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else { } else {
text = lastRefreshTime.toLocaleString('es-ES'); // Use absolute time for older refreshes
text = lastRefreshTime.toLocaleString();
} }
display.textContent = text; display.textContent = text;
display.title = lastRefreshTime.toLocaleString('es-ES'); display.title = lastRefreshTime.toLocaleString(); // Full timestamp on hover
} else { } else {
display.textContent = 'Nunca'; display.textContent = 'Never';
display.title = 'La sincronización aún no se ha ejecutado'; display.title = 'Sync has not run yet since server started';
} }
} catch (err) { } catch (err) {
console.error('Error fetching sync status:', err); console.error('Error fetching sync status:', err);
display.textContent = '—'; display.textContent = 'Unknown';
display.title = 'No se pudo obtener el estado de sincronización'; display.title = 'Could not fetch sync status';
} }
} }
+29 -257
View File
@@ -79,11 +79,6 @@ class WatchPage {
this.isFavorite = false; this.isFavorite = false;
this.returnPage = null; this.returnPage = null;
this.captionsMenuOpen = false; this.captionsMenuOpen = false;
this.sourceStreamUrl = null;
this.knownDurationSec = 0;
this.playbackBaseOffsetSec = 0;
this.isSessionMode = false;
this._heartbeatTimer = null;
// Overlay timer // Overlay timer
this.overlayTimeout = null; this.overlayTimeout = null;
@@ -243,20 +238,6 @@ class WatchPage {
this.scrollHint?.classList.remove('hidden'); this.scrollHint?.classList.remove('hidden');
} }
}); });
// On page close/refresh: stop heartbeat so the 3-min idle timeout kicks in fast
window.addEventListener('beforeunload', () => {
this._stopHeartbeat();
});
// Also stop session when the page becomes hidden (tab switch, minimize)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this._stopHeartbeat();
} else if (this.currentSessionId) {
this._startHeartbeat(this.currentSessionId);
}
});
} }
/** /**
@@ -272,9 +253,6 @@ class WatchPage {
this.currentEpisode = content.currentEpisode || null; this.currentEpisode = content.currentEpisode || null;
this.resumeTime = content.resumeTime || 0; this.resumeTime = content.resumeTime || 0;
this.containerExtension = content.containerExtension || 'mp4'; this.containerExtension = content.containerExtension || 'mp4';
this.knownDurationSec = this.parseDurationSeconds(content.durationSec || content.duration || content.runtime || '');
this.playbackBaseOffsetSec = 0;
this.isSessionMode = false;
this.returnPage = content.type === 'movie' ? 'movies' : 'series'; this.returnPage = content.type === 'movie' ? 'movies' : 'series';
// Stop any Live TV playback before starting movie/series // Stop any Live TV playback before starting movie/series
@@ -293,14 +271,6 @@ class WatchPage {
// Update title bar // Update title bar
this.titleEl.textContent = content.title || ''; this.titleEl.textContent = content.title || '';
this.subtitleEl.textContent = content.subtitle || ''; this.subtitleEl.textContent = content.subtitle || '';
if (this.timeTotal) {
this.timeTotal.textContent = this.knownDurationSec > 0 ? this.formatTime(this.knownDurationSec) : '0:00';
}
// If duration is missing, fetch it from vod_info in the background
if (this.knownDurationSec === 0 && content.sourceId && (content.vodId || content.id)) {
this._fetchVodDuration(content.sourceId, content.vodId || content.id);
}
// Load video // Load video
await this.loadVideo(streamUrl); await this.loadVideo(streamUrl);
@@ -338,7 +308,7 @@ class WatchPage {
const indicator = document.getElementById('now-playing-indicator'); const indicator = document.getElementById('now-playing-indicator');
const textEl = document.getElementById('now-playing-text'); const textEl = document.getElementById('now-playing-text');
if (indicator && textEl) { if (indicator && textEl) {
textEl.textContent = title || 'Reproduciendo'; textEl.textContent = title || 'Now Playing';
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
} }
} }
@@ -371,25 +341,11 @@ class WatchPage {
if (!res.ok) throw new Error('Failed to start session'); if (!res.ok) throw new Error('Failed to start session');
const session = await res.json(); const session = await res.json();
this.currentSessionId = session.sessionId; this.currentSessionId = session.sessionId;
this.isSessionMode = true;
this._startHeartbeat(session.sessionId);
// Use server-probed duration if we don't already have one
if (session.durationSec > 0 && this.knownDurationSec === 0) {
this.knownDurationSec = session.durationSec;
console.log(`[WatchPage] Duration from FFprobe: ${session.durationSec}s`);
if (this.timeTotal) {
this.timeTotal.textContent = this.formatTime(session.durationSec);
}
}
return session.playlistUrl; return session.playlistUrl;
} catch (err) { } catch (err) {
console.error('[WatchPage] Session start failed:', err); console.error('[WatchPage] Session start failed:', err);
this.isSessionMode = false; // Fallback to direct transcode if session fails
// No direct /api/transcode fallback: evita abrir conexiones duplicadas return `/api/transcode?url=${encodeURIComponent(url)}`;
// contra proveedores con límite estricto de sesiones (Dispatcharr/Jellyfin).
throw err;
} }
} }
@@ -397,21 +353,15 @@ class WatchPage {
* Stop and cleanup current transcode session * Stop and cleanup current transcode session
*/ */
async stopTranscodeSession() { async stopTranscodeSession() {
this._stopHeartbeat();
if (this.currentSessionId) { if (this.currentSessionId) {
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId); console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
const sid = this.currentSessionId;
this.currentSessionId = null;
try { try {
// sendBeacon works even when the page is being unloaded // Fire and forget cleanup
if (navigator.sendBeacon) { fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
}
// Also DELETE (for normal navigation)
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
} catch (err) { } catch (err) {
console.error('Failed to stop session:', err); console.error('Failed to stop session:', err);
} }
this.currentSessionId = null;
} }
} }
@@ -462,7 +412,6 @@ class WatchPage {
async loadVideo(url) { async loadVideo(url) {
// Store the URL for copy functionality // Store the URL for copy functionality
this.currentUrl = url; this.currentUrl = url;
this.sourceStreamUrl = url;
// Stop any existing playback // Stop any existing playback
this.stop(); this.stop();
@@ -470,23 +419,6 @@ class WatchPage {
// Show loading spinner // Show loading spinner
this.showLoading(); this.showLoading();
if (typeof window.kiratvPlayVodInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
this.hideLoading();
const absUrl = window.kiratvAbsolutePlaybackUrl(url);
try {
const result = await window.kiratvPlayVodInNativePlayer(absUrl);
if (!result?.ok) {
console.error('[KiraTV Desktop] VOD mpv:', result?.error);
this.updateTranscodeStatus('hidden');
} else {
this.updateTranscodeStatus('direct', 'mpv nativo');
}
} catch (e) {
console.error('[KiraTV Desktop] VOD', e);
}
return;
}
// Get settings for proxy/transcode // Get settings for proxy/transcode
let settings = {}; let settings = {};
try { try {
@@ -530,7 +462,6 @@ class WatchPage {
audioCodec: info.audio, audioCodec: info.audio,
audioChannels: info.audioChannels audioChannels: info.audioChannels
}); });
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl); this.playHls(playlistUrl);
this.setVolumeFromStorage(); this.setVolumeFromStorage();
return; return;
@@ -550,30 +481,8 @@ class WatchPage {
// Compatible - fall through to normal playback // Compatible - fall through to normal playback
console.log('[WatchPage] Auto: Using normal playback (compatible)'); console.log('[WatchPage] Auto: Using normal playback (compatible)');
} catch (err) { } catch (err) {
console.warn('[WatchPage] Probe failed:', err.message); console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
const vodLike = // Continue with normal playback on probe failure
isDirectVideo ||
url.includes('/movie/') ||
url.includes('/series/');
if (vodLike) {
console.warn(
'[WatchPage] Auto: VOD-like URL after probe failure — HLS encode fallback (evita reproducir remux Jellyfin sin transcodificar)'
);
this.updateTranscodeStatus('transcoding', 'Transcoding (auto, probe fallback)');
try {
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'encode',
seekOffset: this.resumeTime,
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
} catch (fallbackErr) {
console.error('[WatchPage] Encode fallback failed:', fallbackErr);
}
}
// Live u otro contenido: seguir reproducción directa habitual
} }
} }
@@ -587,7 +496,6 @@ class WatchPage {
videoMode: 'encode', videoMode: 'encode',
seekOffset: this.resumeTime seekOffset: this.resumeTime
}); });
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl); this.playHls(playlistUrl);
this.setVolumeFromStorage(); this.setVolumeFromStorage();
return; return;
@@ -611,7 +519,6 @@ class WatchPage {
videoCodec, videoCodec,
seekOffset: this.resumeTime seekOffset: this.resumeTime
}); });
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl); this.playHls(playlistUrl);
this.setVolumeFromStorage(); this.setVolumeFromStorage();
return; return;
@@ -623,7 +530,6 @@ class WatchPage {
this.updateTranscodeStatus('remuxing', 'Remux (Force)'); this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`; const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl; this.video.src = finalUrl;
this.playbackBaseOffsetSec = 0;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
}); });
@@ -641,12 +547,10 @@ class WatchPage {
// Use HLS.js for HLS streams // Use HLS.js for HLS streams
if (looksLikeHls && Hls.isSupported()) { if (looksLikeHls && Hls.isSupported()) {
this.updateTranscodeStatus('direct', 'Direct HLS'); this.updateTranscodeStatus('direct', 'Direct HLS');
this.playbackBaseOffsetSec = 0;
this.playHls(finalUrl); this.playHls(finalUrl);
} else { } else {
// Direct playback for mp4/mkv/avi // Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play'); this.updateTranscodeStatus('direct', 'Direct Play');
this.playbackBaseOffsetSec = 0;
this.video.src = finalUrl; this.video.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -707,7 +611,7 @@ class WatchPage {
} }
setVolumeFromStorage() { setVolumeFromStorage() {
const savedVolume = localStorage.getItem('kiratv-volume') || '80'; const savedVolume = localStorage.getItem('nodecast-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100; this.video.volume = parseInt(savedVolume) / 100;
if (this.volumeSlider) this.volumeSlider.value = savedVolume; if (this.volumeSlider) this.volumeSlider.value = savedVolume;
} }
@@ -717,13 +621,6 @@ class WatchPage {
this.stopHistoryTracking(); this.stopHistoryTracking();
this.saveProgress(); this.saveProgress();
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
window.kiratvDesktop.stopExternalMpv().catch(() => {});
}
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
// Cleanup transcode session if exists // Cleanup transcode session if exists
this.stopTranscodeSession(); this.stopTranscodeSession();
this.updateTranscodeStatus('hidden'); this.updateTranscodeStatus('hidden');
@@ -740,12 +637,8 @@ class WatchPage {
} }
if (this.video) { if (this.video) {
this.video.pause(); this.video.pause();
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error this.video.src = '';
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute). this.video.load();
if (!window.kiratvDesktop?.isDesktopApp) {
this.video.src = '';
this.video.load();
}
} }
this.hideNowPlaying(); this.hideNowPlaying();
@@ -762,38 +655,15 @@ class WatchPage {
} }
skip(seconds) { skip(seconds) {
if (!this.video) return; if (this.video) {
const totalDuration = this.getTotalDuration(); this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
const currentAbsolute = this.getAbsoluteCurrentTime();
if (totalDuration > 0) {
const target = Math.max(0, Math.min(currentAbsolute + seconds, totalDuration));
this.seekToAbsolute(target);
} else {
this.video.currentTime = Math.max(0, this.video.currentTime + seconds);
} }
} }
seek(percent) { seek(percent) {
const totalDuration = this.getTotalDuration(); if (this.video && this.video.duration) {
if (!this.video || !totalDuration) return; this.video.currentTime = (percent / 100) * this.video.duration;
const target = (percent / 100) * totalDuration;
this.seekToAbsolute(target);
}
seekToAbsolute(targetSec) {
if (!this.video) return;
if (this.isSessionMode && this.sourceStreamUrl) {
// Session-based HLS: the playlist only covers a window from the last seekOffset.
// Re-create the session from the new target position.
const offset = Math.max(0, Math.floor(targetSec));
this.playbackBaseOffsetSec = offset;
this.resumeTime = offset; // startTranscodeSession reads this for seekOffset
this.loadVideo(this.sourceStreamUrl);
return;
} }
this.video.currentTime = Math.max(0, targetSec);
} }
toggleMute() { toggleMute() {
@@ -807,7 +677,7 @@ class WatchPage {
if (this.video) { if (this.video) {
this.video.volume = value / 100; this.video.volume = value / 100;
this.video.muted = false; this.video.muted = false;
localStorage.setItem('kiratv-volume', value); localStorage.setItem('nodecast-volume', value);
this.updateVolumeUI(); this.updateVolumeUI();
} }
} }
@@ -898,22 +768,18 @@ class WatchPage {
// === UI Updates === // === UI Updates ===
updateProgress() { updateProgress() {
if (!this.video) return; if (!this.video || !this.video.duration) return;
const totalDuration = this.getTotalDuration(); const percent = (this.video.currentTime / this.video.duration) * 100;
if (!totalDuration) return; this.progressSlider.value = percent;
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
const currentAbs = this.getAbsoluteCurrentTime();
const percent = (currentAbs / totalDuration) * 100;
this.progressSlider.value = Math.max(0, Math.min(100, percent));
this.timeCurrent.textContent = this.formatTime(currentAbs);
// Show "Up Next" panel early for series (like streaming services do during credits) // Show "Up Next" panel early for series (like streaming services do during credits)
// Only show if auto-play next episode is enabled // Only show if auto-play next episode is enabled
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode; const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) { if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
const duration = totalDuration; const duration = this.video.duration;
const currentTime = currentAbs; const currentTime = this.video.currentTime;
// Only proceed if we have reliable duration data // Only proceed if we have reliable duration data
if (isFinite(duration) && duration >= 180 && currentTime >= 120) { if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
@@ -941,10 +807,8 @@ class WatchPage {
this.updateQualityBadge(); this.updateQualityBadge();
} }
// Handle resumption (only for direct video, NOT session mode). // Handle resumption
// In session mode FFmpeg already starts from the seekOffset, so if (this.resumeTime > 0 && this.video) {
// video.currentTime=0 already represents the correct absolute position.
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
const duration = this.video.duration; const duration = this.video.duration;
// Only resume if not near the end (95%) // Only resume if not near the end (95%)
if (!duration || this.resumeTime < duration * 0.95) { if (!duration || this.resumeTime < duration * 0.95) {
@@ -952,14 +816,6 @@ class WatchPage {
this.video.currentTime = this.resumeTime; this.video.currentTime = this.resumeTime;
} }
this.resumeTime = 0; // Reset after use this.resumeTime = 0; // Reset after use
} else if (this.isSessionMode) {
this.resumeTime = 0; // Clear so it's not applied again
}
// Update total duration label using known VOD runtime when browser reports Infinity/NaN
const totalDuration = this.getTotalDuration();
if (this.timeTotal && totalDuration > 0) {
this.timeTotal.textContent = this.formatTime(totalDuration);
} }
} }
@@ -996,11 +852,11 @@ class WatchPage {
} }
onError(e) { onError(e) {
// Only log actual fatal errors, not benign stream recovery events
const error = this.video?.error; const error = this.video?.error;
if (!error || !error.code) return; if (error && error.code) {
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar) console.error('[WatchPage] Video error:', error.code, error.message);
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return; }
console.error('[WatchPage] Video error:', error.code, error.message);
} }
updateVolumeUI() { updateVolumeUI() {
@@ -1020,90 +876,6 @@ class WatchPage {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
} }
parseDurationSeconds(value) {
if (!value) return 0;
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const str = String(value).trim();
if (!str) return 0;
// HH:MM:SS or MM:SS (allow any number of digits in the first segment)
if (/^\d+:\d{1,2}(:\d{1,2})?$/.test(str)) {
const parts = str.split(':').map(n => parseInt(n, 10));
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
}
// Plain seconds string
const asNum = parseInt(str, 10);
return Number.isFinite(asNum) ? Math.max(0, asNum) : 0;
}
_startHeartbeat(sessionId) {
this._stopHeartbeat();
// Ping every 90 seconds — well within the 3-minute idle timeout on the worker
this._heartbeatTimer = setInterval(async () => {
if (!sessionId) return;
try {
await fetch(`/api/transcode/${sessionId}/heartbeat`, { method: 'POST' });
} catch (_) { /* ignore */ }
}, 90_000);
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
/**
* Fetch VOD/episode duration from the Xtream vod_info endpoint in the background.
* Updates knownDurationSec and the UI time label once the response arrives.
*/
async _fetchVodDuration(sourceId, vodId) {
try {
const res = await fetch(`/api/proxy/xtream/${sourceId}/vod_info?vod_id=${vodId}`);
if (!res.ok) return;
const data = await res.json();
const raw = data?.info?.duration_secs
|| data?.info?.duration
|| data?.movie_data?.duration_secs
|| data?.info?.runtime;
const secs = this.parseDurationSeconds(raw);
if (secs > 0 && secs > this.knownDurationSec) {
console.log(`[WatchPage] VOD duration from vod_info: ${secs}s`);
this.knownDurationSec = secs;
if (this.timeTotal) {
this.timeTotal.textContent = this.formatTime(secs);
}
}
} catch (e) {
// Non-critical — silently ignore
}
}
getTotalDuration() {
// In session mode (HLS generated by our worker), video.duration only
// reflects the segments generated so far, NOT the full movie length.
// Always prefer the known VOD duration in this case.
if (this.isSessionMode && this.knownDurationSec > 0) {
return this.knownDurationSec;
}
const mediaDuration = this.video?.duration;
if (Number.isFinite(mediaDuration) && mediaDuration > 0) {
const total = mediaDuration + (this.playbackBaseOffsetSec || 0);
// Even outside session mode, pick whichever is larger
return (this.knownDurationSec > total) ? this.knownDurationSec : total;
}
return this.knownDurationSec || 0;
}
getAbsoluteCurrentTime() {
if (!this.video) return 0;
return (this.playbackBaseOffsetSec || 0) + (this.video.currentTime || 0);
}
// === Loading Spinner === // === Loading Spinner ===
showLoading() { showLoading() {
@@ -1645,7 +1417,7 @@ class WatchPage {
try { try {
const data = { const data = {
title: this.content.title || 'Unknown Title', title: this.content.title || 'Unknown Title',
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Película' : 'Serie'), subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Movie' : 'Series'),
poster: this.content.poster, poster: this.content.poster,
sourceId: this.content.sourceId, sourceId: this.content.sourceId,
containerExtension: this.containerExtension, containerExtension: this.containerExtension,
-401
View File
@@ -1,401 +0,0 @@
/**
* Movies Page Controller
* Handles VOD movie browsing and playback
*/
class MoviesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('movies-grid');
this.sourceSelect = document.getElementById('movies-source-select');
this.categorySelect = document.getElementById('movies-category-select');
this.searchInput = document.getElementById('movies-search');
this.movies = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredMovies = [];
this.isLoading = false;
this.observer = null;
this.favoriteIds = new Set(); // Track favorite movie IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadMovies();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadMovies();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('movies-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load movies if empty
if (this.movies.length === 0) {
await this.loadCategories();
await this.loadMovies();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'movie');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set(); // Track hidden categories
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'vod_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.vodCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadMovies() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.movies = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue; // Skip this source if category is from different source
}
}
const movies = await API.proxy.xtream.vodStreams(source.id, catId);
console.log(`[Movies] Source ${source.id}, Category ${catId || 'ALL'}: Got ${movies?.length || 0} movies`);
if (movies && Array.isArray(movies)) {
movies.forEach(m => {
// Skip movies from hidden categories
if (this.hiddenCategoryIds && this.hiddenCategoryIds.has(`${source.id}:${m.category_id}`)) {
return;
}
this.movies.push({
...m,
sourceId: source.id,
id: `${source.id}:${m.stream_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load movies from source ${source.id}:`, err.message);
}
}
console.log(`[Movies] Total loaded: ${this.movies.length} movies`);
this.filterAndRender();
} catch (err) {
console.error('Error loading movies:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading movies</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredMovies = this.movies.filter(m => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${m.sourceId}:${m.stream_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !m.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Movies] Displaying ${this.filteredMovies.length} of ${this.movies.length} movies`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredMovies.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'movies-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches (more to fill viewport)
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredMovies.slice(start, end);
console.log(`[Movies] Rendering batch ${this.currentBatch}: ${batch.length} cards (${start}-${end})`);
if (batch.length === 0) {
const loader = this.container.querySelector('.movies-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.movieId = movie.stream_id;
card.dataset.sourceId = movie.sourceId;
const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
const isFav = this.favoriteIds.has(`${movie.sourceId}:${movie.stream_id}`);
card.innerHTML = `
<div class="movie-poster">
<img src="${poster}" alt="${movie.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="movie-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="movie-info">
<div class="movie-title">${movie.name}</div>
<div class="movie-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
// Card click plays movie, but not if clicking favorite button
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(movie, btn);
e.stopPropagation();
} else {
this.playMovie(movie);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.movies-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredMovies.length && loader) {
loader.style.display = 'none';
}
}
async playMovie(movie) {
try {
// Get stream URL for movie using the actual container extension from API
// Xtream API returns container_extension (e.g., 'mp4', 'mkv', 'avi')
const container = movie.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
this.app.pages.watch.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
sourceId: movie.sourceId,
categoryId: movie.category_id,
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing movie:', err);
}
}
async toggleFavorite(movie, btn) {
const favKey = `${movie.sourceId}:${movie.stream_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(movie.sourceId, movie.stream_id, 'movie');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.MoviesPage = MoviesPage;
-502
View File
@@ -1,502 +0,0 @@
/**
* Series Page Controller
* Handles TV series browsing and playback
*/
class SeriesPage {
constructor(app) {
this.app = app;
this.container = document.getElementById('series-grid');
this.sourceSelect = document.getElementById('series-source-select');
this.categorySelect = document.getElementById('series-category-select');
this.searchInput = document.getElementById('series-search');
this.detailsPanel = document.getElementById('series-details');
this.seasonsContainer = document.getElementById('series-seasons');
this.seriesList = [];
this.categories = [];
this.sources = [];
this.currentBatch = 0;
this.batchSize = 24;
this.filteredSeries = [];
this.isLoading = false;
this.observer = null;
this.hiddenCategoryIds = new Set();
this.currentSeries = null;
this.favoriteIds = new Set(); // Track favorite series IDs
this.showFavoritesOnly = false;
this.init();
}
init() {
// Source change handler
this.sourceSelect?.addEventListener('change', async () => {
await this.loadCategories();
await this.loadSeries();
});
// Category change handler
this.categorySelect?.addEventListener('change', () => {
this.loadSeries();
});
// Search with debounce
let searchTimeout;
this.searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
});
// Back button
document.querySelector('.series-back-btn')?.addEventListener('click', () => {
this.hideDetails();
});
// Set up IntersectionObserver for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.isLoading) {
this.renderNextBatch();
}
}, { rootMargin: '200px' });
// Favorites filter toggle
const favBtn = document.getElementById('series-favorites-btn');
favBtn?.addEventListener('click', () => {
this.showFavoritesOnly = !this.showFavoritesOnly;
favBtn.classList.toggle('active', this.showFavoritesOnly);
this.filterAndRender();
});
}
async show() {
// Hide details panel when showing page
this.hideDetails();
// Load sources if not loaded
// Load sources if not loaded
if (this.sources.length === 0) {
await this.loadSources();
}
// Load favorites
await this.loadFavorites();
// Load series if empty
if (this.seriesList.length === 0) {
await this.loadCategories();
await this.loadSeries();
}
}
hide() {
// Page is hidden
}
async loadFavorites() {
try {
const favs = await API.favorites.getAll(null, 'series');
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async loadSources() {
try {
const allSources = await API.sources.getAll();
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
this.sources.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
this.sourceSelect.appendChild(option);
});
} catch (err) {
console.error('Error loading sources:', err);
}
}
async loadCategories() {
try {
this.categories = [];
this.hiddenCategoryIds = new Set();
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
const sourceId = this.sourceSelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
// Fetch hidden items for each source
for (const source of sourcesToLoad) {
try {
const hiddenItems = await API.channels.getHidden(source.id);
hiddenItems.forEach(h => {
if (h.item_type === 'series_category') {
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
}
});
} catch (err) {
console.warn(`Failed to load hidden items from source ${source.id}`);
}
}
for (const source of sourcesToLoad) {
try {
const cats = await API.proxy.xtream.seriesCategories(source.id);
if (cats && Array.isArray(cats)) {
cats.forEach(c => {
// Skip hidden categories
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
this.categories.push({ ...c, sourceId: source.id });
}
});
}
} catch (err) {
console.warn(`Failed to load series categories from source ${source.id}:`, err.message);
}
}
// Populate dropdown
this.categories.forEach(c => {
const option = document.createElement('option');
option.value = `${c.sourceId}:${c.category_id}`;
option.textContent = c.category_name;
this.categorySelect.appendChild(option);
});
} catch (err) {
console.error('Error loading categories:', err);
}
}
async loadSeries() {
this.isLoading = true;
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
this.seriesList = [];
const sourceId = this.sourceSelect.value;
const categoryValue = this.categorySelect.value;
const sourcesToLoad = sourceId
? this.sources.filter(s => s.id === parseInt(sourceId))
: this.sources;
for (const source of sourcesToLoad) {
try {
// Parse category if selected
let catId = null;
if (categoryValue) {
const [catSourceId, categoryId] = categoryValue.split(':');
if (parseInt(catSourceId) === source.id) {
catId = categoryId;
} else if (sourceId) {
continue;
}
}
const series = await API.proxy.xtream.series(source.id, catId);
console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`);
if (series && Array.isArray(series)) {
series.forEach(s => {
// Skip series from hidden categories
if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) {
return;
}
this.seriesList.push({
...s,
sourceId: source.id,
id: `${source.id}:${s.series_id}`
});
});
}
} catch (err) {
console.warn(`Failed to load series from source ${source.id}:`, err.message);
}
}
console.log(`[Series] Total loaded: ${this.seriesList.length} series`);
this.filterAndRender();
} catch (err) {
console.error('Error loading series:', err);
this.container.innerHTML = '<div class="empty-state"><p>Error loading series</p></div>';
} finally {
this.isLoading = false;
}
}
filterAndRender() {
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
this.filteredSeries = this.seriesList.filter(s => {
// Filter by favorites if enabled
if (this.showFavoritesOnly) {
const favKey = `${s.sourceId}:${s.series_id}`;
if (!this.favoriteIds.has(favKey)) return false;
}
if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) {
return false;
}
return true;
});
console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`);
this.currentBatch = 0;
this.container.innerHTML = '';
if (this.filteredSeries.length === 0) {
this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
return;
}
// Create loader element
const loader = document.createElement('div');
loader.className = 'series-loader';
loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(loader);
// Render initial batches
for (let i = 0; i < 5; i++) {
this.renderNextBatch();
}
// Start observing loader
this.observer.observe(loader);
}
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredSeries.slice(start, end);
if (batch.length === 0) {
const loader = this.container.querySelector('.series-loader');
if (loader) loader.style.display = 'none';
return;
}
const fragment = document.createDocumentFragment();
batch.forEach(series => {
const card = document.createElement('div');
card.className = 'series-card';
card.dataset.seriesId = series.series_id;
card.dataset.sourceId = series.sourceId;
const poster = series.cover || '/img/placeholder.png';
const year = series.year || series.releaseDate?.substring(0, 4) || '';
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`);
card.innerHTML = `
<div class="series-poster">
<img src="${poster}" alt="${series.name}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<div class="series-play-overlay">
<span class="play-icon">${Icons.play}</span>
</div>
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
</button>
</div>
<div class="series-card-info">
<div class="series-title">${series.name}</div>
<div class="series-meta">
${year ? `<span>${year}</span>` : ''}
${rating ? `<span>${rating}</span>` : ''}
</div>
</div>
`;
card.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) {
const btn = e.target.closest('.favorite-btn');
this.toggleFavorite(series, btn);
e.stopPropagation();
} else {
this.showSeriesDetails(series);
}
});
fragment.appendChild(card);
});
// Insert before loader
const loader = this.container.querySelector('.series-loader');
if (loader) {
this.container.insertBefore(fragment, loader);
} else {
this.container.appendChild(fragment);
}
this.currentBatch++;
// Hide loader if done
if (end >= this.filteredSeries.length && loader) {
loader.style.display = 'none';
}
}
async showSeriesDetails(series) {
this.currentSeries = series;
// Show details panel
this.container.classList.add('hidden');
this.detailsPanel.classList.remove('hidden');
// Set header info
document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
document.getElementById('series-title').textContent = series.name;
document.getElementById('series-plot').textContent = series.plot || '';
// Show loading
this.seasonsContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
try {
// Fetch series info (seasons/episodes)
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
if (!info || !info.episodes) {
this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
return;
}
// Store series info for WatchPage
this.currentSeriesInfo = info;
// Render seasons and episodes
let html = '';
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
seasons.forEach(seasonNum => {
const episodes = info.episodes[seasonNum];
html += `
<div class="season-group">
<div class="season-header">
<span class="season-expander">${Icons.chevronDown}</span>
<span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
</div>
<div class="episode-list">
${episodes.map(ep => `
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
<span class="episode-number">E${ep.episode_num}</span>
<span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
<span class="episode-duration">${ep.duration || ''}</span>
</div>
`).join('')}
</div>
</div>`;
});
this.seasonsContainer.innerHTML = html;
// Add click handlers
this.seasonsContainer.querySelectorAll('.season-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.season-group').classList.toggle('collapsed');
});
});
this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => {
ep.addEventListener('click', () => this.playEpisode(ep));
});
} catch (err) {
console.error('Error loading series info:', err);
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
}
}
hideDetails() {
this.detailsPanel.classList.add('hidden');
this.container.classList.remove('hidden');
this.currentSeries = null;
}
async playEpisode(episodeEl) {
const episodeId = episodeEl.dataset.episodeId;
const sourceId = parseInt(episodeEl.dataset.sourceId);
const container = episodeEl.dataset.container || 'mp4';
// Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Season (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Play in dedicated Watch page
if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
this.app.pages.watch.play({
type: 'series',
id: episodeId,
title: this.currentSeries?.name || 'Series',
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.currentSeries?.cover,
description: this.currentSeries?.plot || '',
year: this.currentSeries?.year,
rating: this.currentSeries?.rating,
sourceId: sourceId,
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum,
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
containerExtension: container
}, result.url);
}
}
} catch (err) {
console.error('Error playing episode:', err);
}
}
async toggleFavorite(series, btn) {
const favKey = `${series.sourceId}:${series.series_id}`;
const isFav = this.favoriteIds.has(favKey);
const iconSpan = btn.querySelector('.fav-icon');
try {
// Optimistic update
if (isFav) {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
btn.title = 'Add to Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
await API.favorites.remove(series.sourceId, series.series_id, 'series');
} else {
this.favoriteIds.add(favKey);
btn.classList.add('active');
btn.title = 'Remove from Favorites';
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
await API.favorites.add(series.sourceId, series.series_id, 'series');
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (isFav) {
this.favoriteIds.add(favKey);
btn.classList.add('active');
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
} else {
this.favoriteIds.delete(favKey);
btn.classList.remove('active');
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
}
}
}
}
window.SeriesPage = SeriesPage;
File diff suppressed because it is too large Load Diff
+144 -179
View File
@@ -1,165 +1,144 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iniciar sesión — KiraTV</title> <title>Login - NodeCast TV</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="css/main.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700&family=Montserrat:wght@600;700;800&display=swap" rel="stylesheet">
<style> <style>
:root {
--navy: #000066;
--navy-mid: #000044;
--gold: #e8d5b5;
--gold-hover: #f5ead8;
--text: #ffffff;
--muted: rgba(255, 255, 255, 0.72);
--border: rgba(255, 255, 255, 0.12);
}
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; padding: 0;
font-family: 'DM Sans', system-ui, sans-serif; background: var(--color-bg-primary);
color: var(--text); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background:
radial-gradient(ellipse 100% 80% at 50% -30%, rgba(255, 255, 255, 0.06), transparent 50%),
radial-gradient(ellipse 70% 50% at 110% 40%, rgba(0, 0, 102, 0.55), transparent),
linear-gradient(165deg, var(--navy) 0%, var(--navy-mid) 100%);
} }
.login-wrap { .login-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 20px;
} }
.login-card { .login-box {
background: var(--color-bg-secondary);
padding: var(--space-2xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%; width: 100%;
max-width: 440px; max-width: 420px;
padding: 2.25rem 2rem; border: 1px solid var(--color-border);
border-radius: 18px;
background: rgba(0, 0, 70, 0.55);
border: 1px solid var(--border);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
backdrop-filter: blur(14px);
} }
.brand-block { .login-logo {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: var(--space-2xl);
} }
.login-brand-img { .login-logo .logo-icon {
display: block; width: 64px;
margin: 0 auto; height: 64px;
max-width: min(300px, 100%); margin: 0 auto var(--space-md);
height: auto; color: var(--color-accent);
max-height: 220px;
object-fit: contain;
border-radius: 16px;
filter: drop-shadow(0 16px 40px rgba(0, 0, 0, 0.45));
} }
.brand-tagline { .login-logo h1 {
margin: 0.75rem 0 0; font-family: 'Century Gothic', sans-serif;
font-size: 0.7rem; font-size: 28px;
font-weight: 700; font-weight: 500;
letter-spacing: 0.35em; color: var(--color-text-primary);
text-transform: uppercase; margin: 0;
color: var(--gold);
opacity: 0.9;
} }
#login-subtitle { .login-logo .brand-accent {
margin: 0.5rem 0 0; color: var(--color-accent);
font-size: 0.95rem; }
color: var(--muted);
.login-logo p {
color: var(--color-text-secondary);
margin: var(--space-sm) 0 0 0;
font-size: 14px;
} }
.form-group { .form-group {
margin-bottom: 1.1rem; margin-bottom: var(--space-lg);
} }
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.35rem; margin-bottom: var(--space-sm);
font-size: 0.875rem; color: var(--color-text-primary);
font-weight: 600; font-weight: 500;
color: var(--text); font-size: 14px;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 16px;
border-radius: 10px; background: var(--color-bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border);
background: rgba(8, 12, 20, 0.65); border-radius: var(--radius-md);
color: var(--text); color: var(--color-text-primary);
font-size: 1rem; font-size: 15px;
font-family: inherit; font-family: 'Outfit', sans-serif;
transition: border-color 0.2s, box-shadow 0.2s; transition: all 0.2s;
box-sizing: border-box;
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: rgba(232, 213, 181, 0.5); border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(232, 213, 181, 0.1); background: var(--color-bg-hover);
} }
.form-group input::placeholder { .form-group input::placeholder {
color: rgba(168, 176, 196, 0.55); color: var(--color-text-muted);
} }
.btn-login { .btn-login {
width: 100%; width: 100%;
margin-top: 0.25rem;
padding: 14px; padding: 14px;
background: var(--color-accent);
color: white;
border: none; border: none;
border-radius: 12px; border-radius: var(--radius-md);
font-size: 1rem; font-size: 16px;
font-weight: 700; font-weight: 600;
font-family: 'Montserrat', sans-serif; font-family: 'Outfit', sans-serif;
cursor: pointer; cursor: pointer;
background: linear-gradient(135deg, var(--gold) 0%, #a67c1f 100%); transition: all 0.2s;
color: var(--navy);
transition: transform 0.15s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(232, 213, 181, 0.18);
} }
.btn-login:hover:not(:disabled) { .btn-login:hover {
background: linear-gradient(135deg, var(--gold-hover) 0%, var(--gold) 100%); background: var(--color-accent-hover);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 8px 28px rgba(232, 213, 181, 0.22); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.btn-login:active {
transform: translateY(0);
} }
.btn-login:disabled { .btn-login:disabled {
opacity: 0.55; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.error-message { .error-message {
background: rgba(239, 68, 68, 0.1);
color: var(--color-error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
border: 1px solid rgba(239, 68, 68, 0.2);
font-size: 14px;
display: none; display: none;
margin-bottom: 1rem;
padding: 12px 14px;
border-radius: 10px;
font-size: 0.9rem;
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
} }
.error-message.show { .error-message.show {
@@ -167,128 +146,103 @@
} }
.setup-message { .setup-message {
background: rgba(99, 102, 241, 0.1);
color: var(--color-accent);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
border: 1px solid var(--color-accent-dim);
font-size: 14px;
display: none; display: none;
margin-bottom: 1rem;
padding: 12px 14px;
border-radius: 10px;
font-size: 0.9rem;
background: rgba(232, 213, 181, 0.08);
border: 1px solid rgba(232, 213, 181, 0.22);
color: var(--gold-hover);
} }
.setup-message.show { .setup-message.show {
display: block; display: block;
} }
.sso-divider {
text-align: center;
margin: 1.35rem 0;
position: relative;
}
.sso-divider span {
position: relative;
z-index: 1;
padding: 0 12px;
font-size: 0.8rem;
color: var(--muted);
background: rgba(15, 22, 38, 0.92);
}
.sso-divider::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: rgba(255, 255, 255, 0.08);
z-index: 0;
}
#btn-sso-login {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: var(--text);
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
#btn-sso-login:hover {
background: rgba(232, 213, 181, 0.06);
border-color: rgba(232, 213, 181, 0.35);
color: var(--gold-hover);
}
</style> </style>
</head> </head>
<body> <body>
<div class="login-wrap"> <div class="login-container">
<div class="login-card"> <div class="login-box">
<div class="brand-block"> <div class="login-logo">
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV — Plataforma IPTV" width="320" height="320" decoding="async" class="login-brand-img"> <svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6"
<p class="brand-tagline">Plataforma IPTV</p> fill="currentColor">
<p id="login-subtitle">Inicia sesión para continuar</p> <path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg>
<h1>nodecast-tv</h1>
<p id="login-subtitle">Sign in to continue</p>
</div> </div>
<div class="error-message" id="error-message"></div> <div class="error-message" id="error-message"></div>
<div class="setup-message" id="setup-message"> <div class="setup-message" id="setup-message">
Bienvenido. Crea tu cuenta de administrador para empezar. Welcome! Please create your admin account to get started.
</div> </div>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Usuario</label> <label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username" <input type="text" id="username" name="username" required autocomplete="username"
placeholder="Tu nombre de usuario"> placeholder="Enter your username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Contraseña</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password" <input type="password" id="password" name="password" required autocomplete="current-password"
minlength="6" placeholder="Tu contraseña"> minlength="6" placeholder="Enter your password">
</div> </div>
<button type="submit" class="btn-login" id="submit-btn">Entrar</button> <button type="submit" class="btn-login" id="submit-btn">Sign In</button>
</form> </form>
<div class="sso-divider"> <div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
<span>o</span> <span
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
<div
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
</div>
</div> </div>
<button type="button" id="btn-sso-login"> <button id="btn-sso-login" class="btn-login"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:20px;height:20px;"> style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" /> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
style="width: 20px; height: 20px;">
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg> </svg>
Entrar con SSO Sign in with SSO
</button> </button>
<style>
#btn-sso-login:hover {
background: var(--color-bg-tertiary) !important;
border-color: var(--color-accent) !important;
color: var(--color-accent) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>
</div> </div>
</div> </div>
<script> <script>
// Start SSO Login
document.getElementById('btn-sso-login').addEventListener('click', () => { document.getElementById('btn-sso-login').addEventListener('click', () => {
window.location.href = '/api/auth/oidc/login'; window.location.href = '/api/auth/oidc/login';
}); });
// Check for URL Error params
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error'); const error = urlParams.get('error');
if (error) { if (error) {
const errorMessage = document.getElementById('error-message'); const errorMessage = document.getElementById('error-message');
errorMessage.textContent = error; errorMessage.textContent = error;
errorMessage.classList.add('show'); errorMessage.classList.add('show');
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
} }
// Check if setup is required
let isSetupMode = false; let isSetupMode = false;
async function checkSetupRequired() { async function checkSetupRequired() {
@@ -298,10 +252,11 @@
if (data.setupRequired) { if (data.setupRequired) {
isSetupMode = true; isSetupMode = true;
document.getElementById('login-subtitle').textContent = 'Crea la cuenta de administrador'; document.getElementById('login-subtitle').textContent = 'Create Admin Account';
document.getElementById('submit-btn').textContent = 'Crear cuenta'; document.getElementById('submit-btn').textContent = 'Create Account';
document.getElementById('setup-message').classList.add('show'); document.getElementById('setup-message').classList.add('show');
document.getElementById('password').placeholder = 'Mínimo 6 caracteres'; document.getElementById('password').placeholder = 'Minimum 6 characters';
// Hide SSO in setup mode
document.querySelector('.sso-divider').style.display = 'none'; document.querySelector('.sso-divider').style.display = 'none';
document.getElementById('btn-sso-login').style.display = 'none'; document.getElementById('btn-sso-login').style.display = 'none';
} }
@@ -310,6 +265,7 @@
} }
} }
// Handle form submission
document.getElementById('login-form').addEventListener('submit', async (e) => { document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@@ -318,38 +274,47 @@
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
const errorMessage = document.getElementById('error-message'); const errorMessage = document.getElementById('error-message');
// Clear previous errors
errorMessage.classList.remove('show'); errorMessage.classList.remove('show');
errorMessage.textContent = ''; errorMessage.textContent = '';
// Disable button
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Espera…'; submitBtn.textContent = 'Please wait...';
try { try {
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login'; const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Error de autenticación'); throw new Error(data.error || 'Authentication failed');
} }
// Store token
localStorage.setItem('authToken', data.token); localStorage.setItem('authToken', data.token);
// Redirect to main app (replace history to prevent back button issues)
window.location.replace('/'); window.location.replace('/');
} catch (err) { } catch (err) {
errorMessage.textContent = err.message; errorMessage.textContent = err.message;
errorMessage.classList.add('show'); errorMessage.classList.add('show');
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = isSetupMode ? 'Crear cuenta' : 'Entrar'; submitBtn.textContent = isSetupMode ? 'Create Account' : 'Sign In';
} }
}); });
// Check setup status on load
checkSetupRequired(); checkSetupRequired();
</script> </script>
</body> </body>
</html> </html>
+3 -11
View File
@@ -100,7 +100,7 @@ function configureJwtStrategy(getUserById) {
} }
return done(null, { return done(null, {
id: Number(user.id), id: user.id,
username: user.username, username: user.username,
role: user.role role: user.role
}); });
@@ -230,18 +230,11 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
*/ */
const requireAuth = passport.authenticate('jwt', { session: false }); const requireAuth = passport.authenticate('jwt', { session: false });
/**
* True if user has admin role (case-insensitive, tolerates stray whitespace).
*/
function isAdminRole(user) {
return !!(user && String(user.role || '').trim().toLowerCase() === 'admin');
}
/** /**
* Middleware: Require admin role * Middleware: Require admin role
*/ */
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!isAdminRole(req.user)) { if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden - Admin access required' }); return res.status(403).json({ error: 'Forbidden - Admin access required' });
} }
next(); next();
@@ -271,6 +264,5 @@ module.exports = {
configureOidcStrategy, configureOidcStrategy,
requireAuth, requireAuth,
requireAdmin, requireAdmin,
requireRole, requireRole
isAdminRole
}; };
-268
View File
@@ -1,268 +0,0 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { Strategy: LocalStrategy } = require('passport-local');
/**
* Authentication and Authorization Module
* Handles user authentication, session management, and role-based access control
* Using Passport.js with JWT tokens
*/
// JWT Secret - In production, use environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
const JWT_EXPIRY = '24h';
/**
* Hash password using bcrypt
*/
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
/**
* Verify password against hash
*/
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/**
* Generate JWT token
*/
function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
}
/**
* Verify JWT token
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
return null;
}
}
/**
* Configure Passport Local Strategy for username/password authentication
*/
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await getUserByUsername(username);
if (!user) {
return done(null, false, { message: 'Invalid credentials' });
}
const isValid = await verifyUserPassword(password, user.passwordHash);
if (!isValid) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
}
/**
* Configure Passport JWT Strategy for token-based authentication
*/
function configureJwtStrategy(getUserById) {
const options = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET
};
passport.use(new JwtStrategy(options, async (payload, done) => {
try {
const user = await getUserById(payload.id);
if (!user) {
return done(null, false);
}
return done(null, {
id: user.id,
username: user.username,
role: user.role
});
} catch (err) {
return done(err, false);
}
}));
}
/**
* Configure Passport session serialization
* Required for OIDC flow which uses sessions
*/
function configureSessionSerialization(getUserById) {
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await getUserById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
}
/**
* Configure Passport OpenID Connect Strategy
*/
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
console.warn('OIDC configuration missing - SSO disabled');
return;
}
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
passport.use(new OpenIDConnectStrategy({
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
scope: ['openid', 'profile', 'email']
},
async (...args) => {
// The done callback is always the last argument
const done = args[args.length - 1];
// Map known arguments
// Standard: issuer, sub, profile, accessToken, refreshToken, done
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
let issuer, sub, profile;
if (args.length === 3) {
// Scenario: (issuer, profile, done)
const arg0 = args[0];
const arg1 = args[1];
if (typeof arg1 === 'object' && arg1.id) {
issuer = arg0;
profile = arg1;
sub = profile.id;
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
issuer = arg0;
sub = arg1;
profile = { id: sub, displayName: 'Unknown' };
}
} else if (args.length >= 4) {
// Assume standard: iss, sub, profile...
issuer = args[0];
sub = args[1];
profile = args[2];
}
if (!sub && profile && profile.id) sub = profile.id;
if (!sub) {
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
}
try {
// 1. Try to find by OIDC ID (sub)
let user = await findUserByOidcId(sub);
// 2. If not found, try to match by email
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
if (!user && email) {
user = await findUserByEmail(email);
// If found by email but no OIDC ID, link them
if (user && !user.oidcId) {
// We don't have a direct update method for specific fields without full user object in this context
// Ideally we'd update the user here. For now, we'll just log in.
// Future: Update user with oidcId
}
}
// 3. If still not found, create new user (JIT Provisioning)
if (!user) {
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
user = await createUser({
username: username,
role: 'viewer', // Default role for SSO users
oidcId: sub,
email: email || null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
}
/**
* Middleware: Require authentication using Passport JWT
*/
const requireAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware: Require admin role
*/
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden - Admin access required' });
}
next();
}
/**
* Middleware: Check for specific role
*/
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return res.status(403).json({ error: `Forbidden - ${role} access required` });
}
next();
};
}
module.exports = {
passport,
hashPassword,
verifyPassword,
generateToken,
verifyToken,
configureLocalStrategy,
configureJwtStrategy,
configureSessionSerialization,
configureOidcStrategy,
requireAuth,
requireAdmin,
requireRole
};
+1 -2
View File
@@ -67,8 +67,7 @@ function getDefaultSettings() {
forceVideoTranscode: false, // Force Video Transcode forceVideoTranscode: false, // Force Video Transcode
forceRemux: false, forceRemux: false,
autoTranscode: true, autoTranscode: true,
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts. streamFormat: 'm3u8',
streamFormat: 'ts',
epgRefreshInterval: '24', epgRefreshInterval: '24',
// User-Agent settings // User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
+2 -95
View File
@@ -27,14 +27,7 @@ app.use(session({
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());
// Serve static files with no-cache for JS/CSS so browser always loads the latest version app.use(express.static(path.join(__dirname, '..', 'public')));
app.use(express.static(path.join(__dirname, '..', 'public'), {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
}
}
}));
// FFMPEG Configuration (optional - for transcoding support) // FFMPEG Configuration (optional - for transcoding support)
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package // Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
@@ -95,86 +88,6 @@ function findFFprobe() {
app.locals.ffmpegPath = findFFmpeg(); app.locals.ffmpegPath = findFFmpeg();
app.locals.ffprobePath = findFFprobe(); app.locals.ffprobePath = findFFprobe();
// Dynamic services loader - collects exports from files in ./services
const fs = require('fs');
const services = {};
try {
const servicesDir = path.join(__dirname, 'services');
const serviceFiles = fs.readdirSync(servicesDir).filter(f => f.endsWith('.js'));
for (const file of serviceFiles) {
const name = file.replace(/\.js$/, '');
try {
services[name] = require(path.join(servicesDir, file));
} catch (e) {
console.warn(`Failed to load service ${file}:`, e.message);
}
}
} catch (e) {
console.warn('No services directory found or failed to read services:', e.message);
}
// Freeze services object to prevent plugins from mutating shared state
Object.freeze(services);
// Plugin loader: loads any .js file inside server/plugins and calls the
// exported function with (app, services).
// Supports both function exports and object exports with lifecycle hooks.
const loadedPlugins = [];
async function loadPlugins() {
try {
const pluginsDir = path.join(__dirname, 'plugins');
if (fs.existsSync(pluginsDir)) {
// Sort plugin files alphabetically for deterministic load order
const pluginFiles = fs.readdirSync(pluginsDir)
.filter(f => f.endsWith('.js'))
.sort();
for (const file of pluginFiles) {
const pluginPath = path.join(pluginsDir, file);
try {
const plugin = require(pluginPath);
// Support both function exports and object exports with lifecycle hooks
if (typeof plugin === 'function') {
// Direct function export (sync or async)
await plugin(app, services);
loadedPlugins.push({ name: file, plugin: null });
console.log(`✓ Loaded plugin: ${file}`);
} else if (plugin && typeof plugin.init === 'function') {
// Object export with init/shutdown lifecycle
await plugin.init(app, services);
loadedPlugins.push({ name: file, plugin });
console.log(`✓ Loaded plugin: ${file} (with lifecycle hooks)`);
} else {
console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`);
}
} catch (err) {
console.error(`✗ Failed to load plugin ${file}:`, err);
}
}
}
} catch (err) {
console.warn('Plugin loader failed:', err.message);
}
}
// Graceful shutdown handler for plugins with shutdown hooks
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down plugins...');
for (const { name, plugin } of loadedPlugins) {
if (plugin && typeof plugin.shutdown === 'function') {
try {
await plugin.shutdown();
console.log(`✓ Shutdown plugin: ${name}`);
} catch (err) {
console.error(`✗ Error shutting down plugin ${name}:`, err);
}
}
}
process.exit(0);
});
// API Routes // API Routes
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
app.use('/api/sources', require('./routes/sources')); app.use('/api/sources', require('./routes/sources'));
@@ -187,7 +100,6 @@ app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history')); app.use('/api/history', require('./routes/history'));
app.use('/api/kirabot', require('./routes/kirabot'));
// Version endpoint // Version endpoint
app.get('/api/version', (req, res) => { app.get('/api/version', (req, res) => {
@@ -207,12 +119,7 @@ app.use((err, req, res, next) => {
}); });
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`KiraTV server running on http://localhost:${PORT}`); console.log(`NodeCast TV server running on http://localhost:${PORT}`);
// Load plugins
await loadPlugins().catch(err => {
console.error('Plugin initialization failed:', err);
});
// Trigger background sync with delay to allow server to settle // Trigger background sync with delay to allow server to settle
setTimeout(async () => { setTimeout(async () => {
-163
View File
@@ -1,163 +0,0 @@
# nodecast-tv Plugin System
This directory allows you to extend the functionality of **nodecast-tv** without modifying the core source code. The server automatically detects and loads any `.js` file placed in this folder at startup.
---
## How It Works
The plugin loader in `server/index.js` scans this directory at startup and loads plugins in **alphabetical order** (sorted by filename). Each plugin file should export either:
- A function (sync or async) that will be called during initialization
- An object with `init()` and optionally `shutdown()` methods for lifecycle management
When the server starts, it calls your plugin's initialization code and passes:
- `app` - The Express application instance
- `services` - A **frozen** (read-only) object containing all internal services
---
## Plugin Patterns
### Pattern 1: Simple Function Export
The simplest plugin pattern - just export a function:
```javascript
/**
* @param {Object} app - The Express application instance
* @param {Object} services - Frozen object containing all internal services
*/
module.exports = function(app, services) {
// Register routes, middleware, etc.
app.get('/api/my-route', (req, res) => {
res.json({ message: 'Hello from plugin!' });
});
};
```
### Pattern 2: Async Function Export
For plugins that need to perform async initialization (database connections, API calls, etc.):
```javascript
module.exports = async function(app, services) {
// Async initialization
await someAsyncSetup();
// Access services
if (services.syncService) {
console.log('Sync service available');
}
// Register routes
app.get('/api/my-route', (req, res) => {
res.json({ status: 'ready' });
});
};
```
### Pattern 3: Lifecycle Hooks (Advanced)
For plugins that need cleanup on shutdown:
```javascript
module.exports = {
/**
* Called during server startup
*/
init: async (app, services) => {
// Setup code
this.interval = setInterval(() => {
console.log('Background task running...');
}, 60000);
app.get('/api/status', (req, res) => {
res.json({ uptime: process.uptime() });
});
},
/**
* Called on SIGTERM (graceful shutdown)
*/
shutdown: async () => {
// Cleanup code
if (this.interval) {
clearInterval(this.interval);
}
console.log('Plugin cleaned up');
}
};
```
---
## Security & Permissions
> [!WARNING]
> Plugins run with **full Node.js permissions** and have access to the Express app and all services. Only install plugins from trusted sources.
**Important notes:**
- The `services` object is **frozen** - you cannot modify or delete services
- Plugins can still mutate properties of service objects (e.g., `services.cache.set()`)
- Plugins can register routes, middleware, and access the file system
- This is appropriate for self-hosted applications where you control what plugins are installed
---
## Available Services
The `services` object contains all modules from `server/services/`:
| Service | Description |
|---------|-------------|
| `cache` | Caching utilities |
| `epgParser` | EPG/XMLTV parsing |
| `hwDetect` | Hardware acceleration detection |
| `m3uParser` | M3U playlist parsing |
| `m3uXtreamAdapter` | Xtream API adapter |
| `syncService` | Channel/EPG synchronization |
| `transcodeSession` | Transcoding session management |
| `xtreamApi` | Xtream API client |
**Example:**
```javascript
module.exports = async function(app, services) {
// Use the EPG parser service
const epgData = await services.epgParser.fetchAndParse('http://example.com/epg.xml');
console.log(`Loaded ${epgData.programmes.length} programmes`);
};
```
---
## Load Order
Plugins are loaded in **alphabetical order** by filename. If you need specific ordering:
```
plugins/
01-database.js # Loads first
02-api-client.js # Loads second
99-cleanup.js # Loads last
```
---
## Testing Your Plugin
1. Place your `.js` file in `server/plugins/`
2. Restart the NodeCast TV server
3. Check the console for plugin loading messages:
- `✓ Loaded plugin: your-plugin.js` - Success
- `⚠ Plugin your-plugin.js does not export...` - Wrong export format
- `✗ Failed to load plugin your-plugin.js` - Error during initialization
---
## Example Use Cases
- **Custom scrapers** - Add support for new streaming sources
- **Notification systems** - Send alerts when new content is available
- **Analytics** - Track viewing patterns
- **Custom APIs** - Expose additional endpoints for integrations
- **Middleware** - Add authentication, logging, or rate limiting
-25
View File
@@ -1,25 +0,0 @@
/**
* Example plugin demonstrating async initialization and service access
* This plugin registers a test route at /api/hello
*/
module.exports = async function (app, services) {
console.log("Plugin 'Hello' activated!");
// Example: Access loaded services
if (services.syncService) {
console.log(" - syncService is available");
}
// Simulate async initialization (e.g., database connection, API setup)
await new Promise(resolve => setTimeout(resolve, 100));
// Register a test route accessible at http://localhost:3000/api/hello
app.get('/api/hello', (req, res) => {
res.json({
message: "The plugin system is working!",
availableServices: Object.keys(services)
});
});
console.log(" - Registered route: GET /api/hello");
};
+24 -66
View File
@@ -1,14 +1,6 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/sqlite'); const { getDb } = require('../db/sqlite');
const { sources } = require('../db');
const { requireAuth } = require('../auth');
const {
getAccessibleSourceIds,
assertCanUseSourceById
} = require('../sourceAccess');
router.use(requireAuth);
// Helper to map API item types to DB types and tables // Helper to map API item types to DB types and tables
function mapItemType(apiType) { function mapItemType(apiType) {
@@ -29,44 +21,27 @@ router.get('/hidden', async (req, res) => {
const { sourceId } = req.query; const { sourceId } = req.query;
const db = getDb(); const db = getDb();
let allowedIds = null; let hidden = [];
if (req.user.role !== 'admin') {
allowedIds = await getAccessibleSourceIds(req, sources);
if (allowedIds.length === 0) {
return res.json([]);
}
}
const resultFormat = (row, itemType) => ({ const resultFormat = (row, itemType) => ({
source_id: row.source_id, source_id: row.source_id,
item_type: itemType, item_type: itemType,
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
}); });
// Query Categories
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`; let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`; let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
let catParams = [];
let itemParams = [];
const params = [];
if (sourceId) { if (sourceId) {
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const sid = parseInt(sourceId, 10);
catQuery += ` AND source_id = ?`; catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`; itemQuery += ` AND source_id = ?`;
catParams.push(sid); const sid = parseInt(sourceId);
itemParams.push(sid); params.push(sid);
} else if (allowedIds) {
const ph = allowedIds.map(() => '?').join(',');
catQuery += ` AND source_id IN (${ph})`;
itemQuery += ` AND source_id IN (${ph})`;
catParams = [...allowedIds];
itemParams = [...allowedIds];
} }
const hiddenCats = db.prepare(catQuery).all(...catParams); const hiddenCats = db.prepare(catQuery).all(...params);
const hiddenItems = db.prepare(itemQuery).all(...itemParams); const hiddenItems = db.prepare(itemQuery).all(...params);
const hidden = [];
hiddenCats.forEach(row => { hiddenCats.forEach(row => {
let apiType; let apiType;
@@ -100,7 +75,6 @@ router.post('/hide', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -127,7 +101,6 @@ router.post('/show', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -154,8 +127,6 @@ router.get('/hidden/check', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.json({ hidden: false }); if (!mapping) return res.json({ hidden: false });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -177,16 +148,13 @@ router.post('/hide/bulk', async (req, res) => {
const { items } = req.body; const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb(); const db = getDb();
// Prepare statements once
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?'); const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?'); const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (hide all children of a category)
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?'); const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => { const runBulk = db.transaction((list) => {
@@ -194,9 +162,12 @@ router.post('/hide/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType); const mapping = mapItemType(item.itemType);
if (mapping) { if (mapping) {
if (mapping.table === 'categories') { if (mapping.table === 'categories') {
// Hide the category
hideCat.run(item.sourceId, mapping.type, item.itemId); hideCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
hideCatChildren.run(item.sourceId, mapping.type, item.itemId); hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else { } else {
// Hide individual item
hideItem.run(item.sourceId, mapping.type, item.itemId); hideItem.run(item.sourceId, mapping.type, item.itemId);
} }
} }
@@ -220,16 +191,13 @@ router.post('/show/bulk', async (req, res) => {
const { items } = req.body; const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb(); const db = getDb();
// Prepare statements once
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?'); const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?'); const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (show all children of a category)
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?'); const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => { const runBulk = db.transaction((list) => {
@@ -237,9 +205,12 @@ router.post('/show/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType); const mapping = mapItemType(item.itemType);
if (mapping) { if (mapping) {
if (mapping.table === 'categories') { if (mapping.table === 'categories') {
// Show the category
showCat.run(item.sourceId, mapping.type, item.itemId); showCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
showCatChildren.run(item.sourceId, mapping.type, item.itemId); showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else { } else {
// Show individual item
showItem.run(item.sourceId, mapping.type, item.itemId); showItem.run(item.sourceId, mapping.type, item.itemId);
} }
} }
@@ -263,15 +234,14 @@ router.post('/show/all', async (req, res) => {
const { sourceId, contentType } = req.body; const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
let catCount = 0; let catCount = 0;
let itemCount = 0; let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie'] const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series'] : contentType === 'series' ? ['series']
: ['live']; : ['live']; // default to channels
for (const type of types) { for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -294,15 +264,14 @@ router.post('/hide/all', async (req, res) => {
const { sourceId, contentType } = req.body; const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
let catCount = 0; let catCount = 0;
let itemCount = 0; let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie'] const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series'] : contentType === 'series' ? ['series']
: ['live']; : ['live']; // default to channels
for (const type of types) { for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -327,18 +296,6 @@ router.get('/recent', async (req, res) => {
return res.status(400).json({ error: 'Valid type (movie or series) is required' }); return res.status(400).json({ error: 'Valid type (movie or series) is required' });
} }
let accessibleFilter = '';
const params = [type];
if (req.user.role !== 'admin') {
const ids = await getAccessibleSourceIds(req, sources);
if (ids.length === 0) {
return res.json([]);
}
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
params.push(...ids);
}
params.push(parseInt(limit, 10));
const db = getDb(); const db = getDb();
const recentItems = db.prepare(` const recentItems = db.prepare(`
SELECT * FROM playlist_items p SELECT * FROM playlist_items p
@@ -351,11 +308,11 @@ router.get('/recent', async (req, res) => {
AND c.type = p.type AND c.type = p.type
AND c.is_hidden = 1 AND c.is_hidden = 1
) )
${accessibleFilter}
ORDER BY p.added_at DESC ORDER BY p.added_at DESC
LIMIT ? LIMIT ?
`).all(...params); `).all(type, parseInt(limit));
// Parse JSON data for each item
const formatted = recentItems.map(item => ({ const formatted = recentItems.map(item => ({
...item, ...item,
data: JSON.parse(item.data) data: JSON.parse(item.data)
@@ -369,3 +326,4 @@ router.get('/recent', async (req, res) => {
}); });
module.exports = router; module.exports = router;
-149
View File
@@ -1,149 +0,0 @@
/**
* KiraBot proxy — forwards /api/kirabot/* to KiraStream backend
* authenticating on behalf of the current kiratv user.
*/
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../auth');
const { sources } = require('../db');
const http = require('http');
const KIRASTREAM_BASE = process.env.KIRASTREAM_BASE_URL || 'http://10.10.10.234';
// Token cache: { userId: { token, expires } }
const _tokenCache = new Map();
async function _getKirastreamToken(user) {
const cached = _tokenCache.get(user.id);
if (cached && cached.expires > Date.now()) {
return cached.token;
}
// Find user's xtream source pointing to kirastream
let db;
try {
db = await require('../db').loadDb();
} catch (e) {
return null;
}
const src = db.sources.find(
(s) => s.ownerId === user.id && s.type === 'xtream' && s.username && s.password
);
if (!src) return null;
// POST form to /api/user/auth/token
return new Promise((resolve) => {
const body = `username=${encodeURIComponent(src.username)}&password=${encodeURIComponent(src.password)}`;
const url = new URL(`${KIRASTREAM_BASE}/api/user/auth/token`);
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.access_token) {
_tokenCache.set(user.id, {
token: json.access_token,
expires: Date.now() + 25 * 60 * 1000, // 25 min
});
resolve(json.access_token);
} else {
resolve(null);
}
} catch (e) {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.write(body);
req.end();
});
}
async function _proxyToKirastream(req, res, path, method, body) {
const token = await _getKirastreamToken(req.user);
if (!token) {
return res.status(401).json({ error: 'No se pudo autenticar con KiraStream' });
}
const url = new URL(`${KIRASTREAM_BASE}/api/user/kirabot${path}`);
const bodyStr = body ? JSON.stringify(body) : '';
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname + (url.search || ''),
method: method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
};
return new Promise((resolve) => {
const proxyReq = http.request(options, (proxyRes) => {
let data = '';
proxyRes.on('data', (chunk) => { data += chunk; });
proxyRes.on('end', () => {
try {
const json = JSON.parse(data);
res.status(proxyRes.statusCode).json(json);
} catch (e) {
res.status(proxyRes.statusCode).send(data);
}
resolve();
});
});
proxyReq.on('error', (err) => {
res.status(502).json({ error: 'Error de conexión con KiraStream', detail: err.message });
resolve();
});
if (bodyStr) proxyReq.write(bodyStr);
proxyReq.end();
});
}
router.use(requireAuth);
router.post('/chat', async (req, res) => {
await _proxyToKirastream(req, res, '/chat', 'POST', req.body);
});
router.get('/chat/history', async (req, res) => {
await _proxyToKirastream(req, res, '/chat/history', 'GET', null);
});
router.delete('/chat/history', async (req, res) => {
await _proxyToKirastream(req, res, '/chat/history', 'DELETE', null);
});
router.get('/status', async (req, res) => {
await _proxyToKirastream(req, res, '/status', 'GET', null);
});
router.get('/recordings', async (req, res) => {
const qs = req.query.limit ? `?limit=${req.query.limit}` : '';
await _proxyToKirastream(req, res, `/recordings${qs}`, 'GET', null);
});
router.post('/recordings', async (req, res) => {
await _proxyToKirastream(req, res, '/recordings', 'POST', req.body);
});
router.delete('/recordings/:id', async (req, res) => {
await _proxyToKirastream(req, res, `/recordings/${req.params.id}`, 'DELETE', null);
});
module.exports = router;
+21 -44
View File
@@ -170,55 +170,32 @@ router.get('/', async (req, res) => {
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`); console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
// Retry probe up to 3 times on 5XX errors (provider slot not released yet) try {
const MAX_PROBE_RETRIES = 3; const probeResult = await probeStream(url, ffprobePath, ua);
const PROBE_RETRY_DELAY_MS = 2500; const analysis = analyzeProbeResult(probeResult, url);
let lastErr = null;
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) { // Cache result
try { probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
// Cache successful result console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() }); `container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` + res.json(analysis);
`container=${analysis.container}, compatible=${analysis.compatible}, ` + } catch (err) {
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`); console.error('[Probe] Failed:', err.message);
return res.json(analysis); // On error, assume transcode needed to be safe
} catch (err) { res.json({
lastErr = err; video: 'unknown',
const is5xx = /5[0-9]{2}|Server Error/i.test(err.message); audio: 'unknown',
if (is5xx && attempt < MAX_PROBE_RETRIES) { container: 'unknown',
console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`); compatible: false,
await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS)); needsRemux: false,
continue; needsTranscode: true,
} error: err.message
break; });
}
} }
console.error('[Probe] Failed after retries:', lastErr.message);
// If there's a stale cached result, use it rather than assuming needsTranscode
const stale = probeCache.get(cacheKey);
if (stale) {
console.warn('[Probe] Using stale cached result as fallback');
return res.json({ ...stale.result, stale: true });
}
// Final fallback: assume transcode needed
res.json({
video: 'unknown',
audio: 'unknown',
container: 'unknown',
compatible: false,
needsRemux: false,
needsTranscode: true,
error: lastErr.message
});
}); });
module.exports = router; module.exports = router;
+18 -47
View File
@@ -12,37 +12,6 @@ const https = require('https');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const ffmpegPath = require('ffmpeg-static'); const ffmpegPath = require('ffmpeg-static');
const { Readable } = require('stream'); const { Readable } = require('stream');
const auth = require('../auth');
const { canUserAccessSource } = require('../sourceAccess');
// Las rutas de proxy (categorías, streams, EPG, imágenes, HLS, etc.) son accedidas
// directamente por el reproductor y los navegadores sin header Authorization.
// El control de acceso a fuentes se hace en /api/sources (CRUD).
// Por eso NO aplicamos requireAuth globalmente aquí.
router.param('sourceId', async (req, res, next, id) => {
try {
const sid = parseInt(id, 10);
if (Number.isNaN(sid)) {
return res.status(400).json({ error: 'Invalid source id' });
}
const source = await sources.getById(sid);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
// Solo bloqueamos si hay usuario autenticado y no tiene acceso
// (sin token, req.user es undefined → dejamos pasar, el reproductor no lleva Bearer)
if (req.user) {
return res.status(403).json({ error: 'No access to this source' });
}
}
req.grantedSource = source;
next();
} catch (err) {
next(err);
}
});
// Default cache max age in hours // Default cache max age in hours
const DEFAULT_MAX_AGE_HOURS = 24; const DEFAULT_MAX_AGE_HOURS = 24;
@@ -114,8 +83,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
// Login / Authenticate // Login / Authenticate
router.get('/xtream/:sourceId', async (req, res) => { router.get('/xtream/:sourceId', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (source.type !== 'xtream') return res.status(404).send('Source not found'); if (!source || source.type !== 'xtream') return res.status(404).send('Source not found');
// Proxy auth check to upstream to ensure credentials are still valid // Proxy auth check to upstream to ensure credentials are still valid
@@ -216,7 +185,8 @@ router.get('/xtream/:sourceId/series', async (req, res) => {
// Proxy series info request // Proxy series info request
router.get('/xtream/:sourceId/series_info', async (req, res) => { router.get('/xtream/:sourceId/series_info', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const seriesId = req.query.series_id; const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required'); if (!seriesId) return res.status(400).send('series_id required');
@@ -237,7 +207,8 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
// VOD Info // VOD Info
router.get('/xtream/:sourceId/vod_info', async (req, res) => { router.get('/xtream/:sourceId/vod_info', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const vodId = req.query.vod_id; const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required'); if (!vodId) return res.status(400).send('vod_id required');
@@ -259,14 +230,14 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
// Returns the direct stream URL for a given stream ID // Returns the direct stream URL for a given stream ID
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => { router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (source.type !== 'xtream') { if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
const streamId = req.params.streamId; const streamId = req.params.streamId;
const type = req.params.type || 'live'; const type = req.params.type || 'live';
const container = req.query.container || 'ts'; const container = req.query.container || 'm3u8';
// Construct the Xtream stream URL // Construct the Xtream stream URL
// Format: http://server:port/live/username/password/streamId.container (for live) // Format: http://server:port/live/username/password/streamId.container (for live)
@@ -422,8 +393,8 @@ router.delete('/cache/:sourceId', (req, res) => {
router.get('/xtream/:sourceId/:action', async (req, res) => { router.get('/xtream/:sourceId/:action', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = req.grantedSource; const source = await sources.getById(sourceId);
if (source.type !== 'xtream') { if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
@@ -507,14 +478,14 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
*/ */
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => { router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (source.type !== 'xtream') { if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
const api = xtreamApi.createFromSource(source); const api = xtreamApi.createFromSource(source);
const { streamId, type = 'live' } = req.params; const { streamId, type = 'live' } = req.params;
const { container = 'ts' } = req.query; const { container = 'm3u8' } = req.query;
const url = api.buildStreamUrl(streamId, type, container); const url = api.buildStreamUrl(streamId, type, container);
res.json({ url }); res.json({ url });
@@ -534,8 +505,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
router.get('/epg/:sourceId', async (req, res) => { router.get('/epg/:sourceId', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = req.grantedSource; const source = await sources.getById(sourceId);
if (source.type !== 'epg' && source.type !== 'xtream') { if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
return res.status(404).json({ error: 'Valid EPG source not found' }); return res.status(404).json({ error: 'Valid EPG source not found' });
} }
@@ -596,8 +567,8 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
*/ */
router.post('/epg/:sourceId/channels', async (req, res) => { router.post('/epg/:sourceId/channels', async (req, res) => {
try { try {
const source = req.grantedSource; const source = await sources.getById(req.params.sourceId);
if (source.type !== 'epg') { if (!source || source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' }); return res.status(404).json({ error: 'EPG source not found' });
} }
+97 -139
View File
@@ -5,85 +5,29 @@ const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi'); const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService'); const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser'); const m3uParser = require('../services/m3uParser');
const { requireAuth, requireAdmin, isAdminRole } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
router.use(requireAuth); // Get all sources
function sanitizeSourceList(list) {
return list.map((s) => ({
...s,
password: s.password ? '••••••••' : null
}));
}
function parseOwnerId(body) {
const raw = body.ownerId;
if (raw === undefined || raw === null || raw === '') return null;
const n = parseInt(raw, 10);
return Number.isNaN(n) ? null : n;
}
// Estimate by URL (must stay before /:id routes)
const M3U_LARGE_THRESHOLD = 50000;
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global sync — admin only
router.post('/sync-all', requireAdmin, async (req, res) => {
try {
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
// Get all sources (filtered by access)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const allSources = await sources.getAll(); const allSources = await sources.getAll();
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s)); // Don't expose passwords in list view
res.json(sanitizeSourceList(filtered)); const sanitized = allSources.map(s => ({
...s,
password: s.password ? '••••••••' : null
}));
res.json(sanitized);
} catch (err) { } catch (err) {
console.error('Error getting sources:', err); console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' }); res.status(500).json({ error: 'Failed to get sources' });
} }
}); });
// Get sync status (filtered for non-admins) // Get sync status for all sources
router.get('/status', async (req, res) => { router.get('/status', async (req, res) => {
try { try {
const { getDb } = require('../db/sqlite');
const db = getDb(); const db = getDb();
let statuses = db.prepare('SELECT * FROM sync_status').all(); const statuses = db.prepare('SELECT * FROM sync_status').all();
if (req.user.role !== 'admin') {
const allowed = new Set(await getAccessibleSourceIds(req, sources));
statuses = statuses.filter((row) => allowed.has(row.source_id));
}
res.json(statuses); res.json(statuses);
} catch (err) { } catch (err) {
console.error('Error getting sync status:', err); console.error('Error getting sync status:', err);
@@ -95,42 +39,13 @@ router.get('/status', async (req, res) => {
router.get('/type/:type', async (req, res) => { router.get('/type/:type', async (req, res) => {
try { try {
const typeSources = await sources.getByType(req.params.type); const typeSources = await sources.getByType(req.params.type);
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s)); res.json(typeSources);
res.json(sanitizeSourceList(filtered));
} catch (err) { } catch (err) {
console.error('Error getting sources by type:', err); console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' }); res.status(500).json({ error: 'Failed to get sources' });
} }
}); });
// Estimate by source ID (before bare GET /:id — same base path length)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(source.url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Get single source // Get single source
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
@@ -138,9 +53,6 @@ router.get('/:id', async (req, res) => {
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
res.json(source); res.json(source);
} catch (err) { } catch (err) {
console.error('Error getting source:', err); console.error('Error getting source:', err);
@@ -161,15 +73,8 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid source type' }); return res.status(400).json({ error: 'Invalid source type' });
} }
let ownerId = null; const source = await sources.create({ type, name, url, username, password });
if (isAdminRole(req.user)) { // Trigger Sync
ownerId = parseOwnerId(req.body);
} else {
const uid = req.user.id != null ? Number(req.user.id) : NaN;
ownerId = Number.isNaN(uid) ? null : uid;
}
const source = await sources.create({ type, name, url, username, password, ownerId });
syncService.syncSource(source.id).catch(console.error); syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source); res.status(201).json(source);
} catch (err) { } catch (err) {
@@ -185,23 +90,16 @@ router.put('/:id', async (req, res) => {
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const { name, url, username, password } = req.body; const { name, url, username, password } = req.body;
const updates = { const updated = await sources.update(req.params.id, {
name: name || existing.name, name: name || existing.name,
url: url || existing.url, url: url || existing.url,
username: username !== undefined ? username : existing.username, username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password password: password !== undefined ? password : existing.password
}; });
if (isAdminRole(req.user) && req.body.ownerId !== undefined) { // Trigger Sync (if critical fields changed? safely just trigger it)
updates.ownerId = parseOwnerId(req.body); syncService.syncSource(parseInt(req.params.id)).catch(console.error);
}
const updated = await sources.update(req.params.id, updates);
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
res.json(updated); res.json(updated);
} catch (err) { } catch (err) {
console.error('Error updating source:', err); console.error('Error updating source:', err);
@@ -212,15 +110,13 @@ router.put('/:id', async (req, res) => {
// Delete source // Delete source
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
const sourceId = parseInt(req.params.id, 10); const sourceId = parseInt(req.params.id);
const existing = await sources.getById(sourceId); const existing = await sources.getById(sourceId);
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Cascade delete: Clean up SQLite data for this source
const db = getDb(); const db = getDb();
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?'); const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?'); const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
@@ -234,6 +130,7 @@ router.delete('/:id', async (req, res) => {
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`); console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
// Delete source config and related hidden items (favorites handled by db.js)
await sources.delete(sourceId); await sources.delete(sourceId);
res.json({ success: true }); res.json({ success: true });
@@ -246,21 +143,14 @@ router.delete('/:id', async (req, res) => {
// Toggle source enabled/disabled // Toggle source enabled/disabled
router.post('/:id/toggle', async (req, res) => { router.post('/:id/toggle', async (req, res) => {
try { try {
const existing = await sources.getById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const updated = await sources.toggleEnabled(req.params.id); const updated = await sources.toggleEnabled(req.params.id);
if (!updated) { if (!updated) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
// If enabled, trigger sync
if (updated.enabled) { if (updated.enabled) {
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error); syncService.syncSource(parseInt(req.params.id)).catch(console.error);
} }
res.json(updated); res.json(updated);
@@ -273,13 +163,11 @@ router.post('/:id/toggle', async (req, res) => {
// Manual Sync // Manual Sync
router.post('/:id/sync', async (req, res) => { router.post('/:id/sync', async (req, res) => {
try { try {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id);
const source = await sources.getById(id); const source = await sources.getById(id);
if (!source) return res.status(404).json({ error: 'Source not found' }); if (!source) return res.status(404).json({ error: 'Source not found' });
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Trigger sync (async)
syncService.syncSource(id).catch(console.error); syncService.syncSource(id).catch(console.error);
res.json({ success: true, message: 'Sync started' }); res.json({ success: true, message: 'Sync started' });
@@ -296,9 +184,6 @@ router.post('/:id/test', async (req, res) => {
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type === 'xtream') { if (source.type === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password); const result = await xtreamApi.authenticate(source.url, source.username, source.password);
@@ -320,4 +205,77 @@ router.post('/:id/test', async (req, res) => {
} }
}); });
// Estimate M3U playlist size (for large playlist warning)
const M3U_LARGE_THRESHOLD = 50000;
// Estimate by URL (for new sources before creation)
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Only M3U sources need estimation
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for URL...`);
const count = await m3uParser.countEntries(url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Estimate by source ID (for existing sources)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
// Only M3U sources need estimation
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
const count = await m3uParser.countEntries(source.url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global Sync - sync all enabled sources
router.post('/sync-all', async (req, res) => {
try {
// Trigger global sync (async - don't wait for completion)
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
module.exports = router; module.exports = router;
+20 -78
View File
@@ -1,39 +1,11 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { spawn, execFile } = require('child_process'); const { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const db = require('../db'); const db = require('../db');
const transcodeSession = require('../services/transcodeSession'); const transcodeSession = require('../services/transcodeSession');
/**
* Probe a stream URL with FFprobe to get its total duration in seconds.
* Times out quickly (8 s) so it doesn't block session creation for long.
*/
function probeDuration(url, ffprobePath, userAgent) {
return new Promise((resolve) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format=duration',
'-user_agent', userAgent || 'Mozilla/5.0',
'-probesize', '3000000',
'-analyzeduration', '2000000',
url
];
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (err, stdout) => {
if (err) { resolve(0); return; }
try {
const data = JSON.parse(stdout);
const dur = parseFloat(data?.format?.duration);
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
} catch { resolve(0); }
});
// Ensure the process is killed if execFile timeout fires
proc.on('error', () => resolve(0));
});
}
/** /**
* Transcode Routes * Transcode Routes
* *
@@ -68,28 +40,25 @@ router.post('/session', async (req, res) => {
const userAgent = db.getUserAgent(settings); const userAgent = db.getUserAgent(settings);
try { try {
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe'); const session = await transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
// Upscaling options
upscaleEnabled: settings.upscaleEnabled || false,
upscaleMethod: settings.upscaleMethod || 'hardware',
upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode, // 'copy' or 'encode'
videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', etc.
audioChannels: audioChannels // number of channels (2=stereo)
});
// Probe duration and start session in parallel (don't block on duration) await session.start();
const [session, durationSec] = await Promise.all([
transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto',
upscaleEnabled: settings.upscaleEnabled || false,
upscaleMethod: settings.upscaleMethod || 'hardware',
upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode,
videoCodec: videoCodec,
audioCodec: audioCodec,
audioChannels: audioChannels
}).then(async s => { await s.start(); return s; }),
probeDuration(url, ffprobePath, userAgent)
]);
// Wait for playlist to be ready (first segments generated) // Wait for playlist to be ready (first segments generated)
const ready = await session.waitForPlaylist(15000); const ready = await session.waitForPlaylist(15000);
@@ -99,13 +68,10 @@ router.post('/session', async (req, res) => {
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' }); return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
} }
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
res.json({ res.json({
sessionId: session.id, sessionId: session.id,
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
status: session.status, status: session.status
durationSec: durationSec || 0
}); });
} catch (err) { } catch (err) {
@@ -178,30 +144,6 @@ router.delete('/:sessionId', async (req, res) => {
} }
}); });
/**
* Heartbeat — keeps a session alive while the player is active.
* POST /api/transcode/:sessionId/heartbeat
*/
router.post('/:sessionId/heartbeat', (req, res) => {
const session = transcodeSession.getSession(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
session.lastAccess = Date.now();
res.json({ ok: true });
});
/**
* Stop session via POST (for sendBeacon on page close)
* POST /api/transcode/:sessionId/stop
*/
router.post('/:sessionId/stop', async (req, res) => {
try {
await transcodeSession.removeSession(req.params.sessionId);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to remove session', details: err.message });
}
});
/** /**
* List all active sessions (for debugging) * List all active sessions (for debugging)
* GET /api/transcode/sessions * GET /api/transcode/sessions
+3 -5
View File
@@ -228,18 +228,16 @@ async function detect() {
detectAMF() detectAMF()
]); ]);
// Determine recommended encoder // Determine recommended encoder (priority: NVENC > AMF > QSV > VAAPI > Software)
// On Linux: prefer VAAPI over QSV — VAAPI is more reliable in LXC containers
// (QSV may fail to initialize the MFX device even when /dev/dri is passed through)
let recommended = 'software'; let recommended = 'software';
if (nvidia.available) { if (nvidia.available) {
recommended = 'nvenc'; recommended = 'nvenc';
} else if (amf.available) { } else if (amf.available) {
recommended = 'amf'; recommended = 'amf';
} else if (vaapi.available) {
recommended = 'vaapi';
} else if (qsv.available) { } else if (qsv.available) {
recommended = 'qsv'; recommended = 'qsv';
} else if (vaapi.available) {
recommended = 'vaapi';
} }
hwCapabilities = { hwCapabilities = {
+3 -12
View File
@@ -26,9 +26,9 @@ const sessions = new Map();
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache'); const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings // Session settings
const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly) const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
const SEGMENT_DURATION = 4; // seconds per HLS segment const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
/** /**
* Generate a unique session ID * Generate a unique session ID
@@ -222,10 +222,7 @@ class TranscodeSession extends EventEmitter {
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') { if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
args.push('-bsf:v', 'hevc_mp4toannexb'); args.push('-bsf:v', 'hevc_mp4toannexb');
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') { } else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
// Keep Annex-B conversion and force parameter sets to be repeated. args.push('-bsf:v', 'h264_mp4toannexb');
// This helps players recover on streams where SPS/PPS appear sporadically,
// which often manifests as "audio OK, black video".
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
} else { } else {
// Fallback (e.g. unknown codec), try strict extraction // Fallback (e.g. unknown codec), try strict extraction
args.push('-bsf:v', 'dump_extra'); args.push('-bsf:v', 'dump_extra');
@@ -554,7 +551,6 @@ class TranscodeSession extends EventEmitter {
/** /**
* Wait for playlist to be ready (with timeout) * Wait for playlist to be ready (with timeout)
* Fast-fails if FFmpeg exited with an error before the timeout.
*/ */
async waitForPlaylist(timeoutMs = 10000) { async waitForPlaylist(timeoutMs = 10000) {
const startTime = Date.now(); const startTime = Date.now();
@@ -562,11 +558,6 @@ class TranscodeSession extends EventEmitter {
if (await this.isPlaylistReady()) { if (await this.isPlaylistReady()) {
return true; return true;
} }
// Fast-fail: FFmpeg already exited with an error — no point waiting
if (this.status === 'error' && !this.process) {
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
return false;
}
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
} }
return false; return false;
-46
View File
@@ -1,46 +0,0 @@
const { isAdminRole } = require('./auth');
/**
* Who may use a source for playback and API access.
* ownerId null/undefined: legacy shared — any authenticated user may use it.
* ownerId N: only that user id (and admins) may use it.
*/
function canUserAccessSource(user, source) {
if (!user || !source) return false;
if (isAdminRole(user)) return true;
const oid = source.ownerId;
if (oid == null || oid === undefined) return true;
const uid = user.id != null ? Number(user.id) : NaN;
const oidN = Number(oid);
return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid;
}
async function getAccessibleSourceIds(req, sourcesApi) {
const all = await sourcesApi.getAll();
return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id);
}
/** Returns false after sending res if missing, forbidden, or invalid. */
async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) {
const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10);
if (Number.isNaN(sid)) {
res.status(400).json({ error: 'Invalid source id' });
return false;
}
const source = await sourcesApi.getById(sid);
if (!source) {
res.status(404).json({ error: 'Source not found' });
return false;
}
if (!canUserAccessSource(req.user, source)) {
res.status(403).json({ error: 'No access to this source' });
return false;
}
return true;
}
module.exports = {
canUserAccessSource,
getAccessibleSourceIds,
assertCanUseSourceById
};