229 Commits

Author SHA1 Message Date
Joaquin bb189a472e Instancia transcoder t1: cambios locales 2026-07-13 15:19:13 +00:00
Technomancer702 0e26a90dae Merge pull request #95 from okynorch/doc-update
Updated readme
2026-03-02 15:10:21 -08:00
fry f3f9d79e8a init 2026-02-16 09:39:42 -08:00
Technomancer702 50d337f87d chore: update plugin readme 2026-02-05 01:46:10 -08:00
Technomancer702 de41aa04fd feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues 2026-02-05 01:42:58 -08:00
Technomancer702 72e14dca57 feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues 2026-02-05 01:42:40 -08:00
Technomancer702 6eafa871b5 Merge pull request #82 from leo3438/main
feat: add modular plugin system and developer documentation
2026-02-04 15:46:10 -08:00
Technomancer702 31aafe4d37 Merge pull request #79 from avandeweghe/patch-1
Refactor parseStreaming to use pendingBatches array
2026-02-04 15:45:56 -08:00
alphaone 146f7dd1b1 feat: add modular plugin system and documentation 2026-02-04 10:48:36 +00:00
avandeweghe bfc80738f4 Refactor parseStreaming to use pendingBatches array
Was running into an issue where epg wouldn't process passed the first batch. Refactored the batching to use an array and added backpressure to keep memory usage low.
2026-01-31 19:31:03 -05:00
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
28 changed files with 1143 additions and 3914 deletions
-5
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.
+10 -5
View File
@@ -1,6 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#000066"/> <defs>
<path fill="#ffffff" d="M14 48V14h10l14 16V14h10v34H38L24 32v16H14z"/> <linearGradient id="g" x1="0" y1="0" x2="64" y2="64">
<path stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" fill="none" d="M36 20c8 0 14 6 14 14s-6 14-14 14"/> <stop offset="0%" stop-color="#7c3aed"/>
<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"/> <stop offset="100%" stop-color="#4f46e5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<rect x="10" y="18" width="44" height="28" rx="4" stroke="#fff" stroke-width="3" fill="none"/>
<path d="M22 32 L28 38 L42 24" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 553 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

-1
View File
@@ -1104,7 +1104,6 @@
<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=3"></script>
<script src="/js/pages/WatchPage.js?v=3"></script> <script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/components/ChatBot.js?v=2"></script>
<script src="/js/app.js?v=5"></script> <script src="/js/app.js?v=5"></script>
<script> <script>
// Check for SSO token in URL // Check for SSO token in URL
-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);
});
+2 -2
View File
@@ -169,7 +169,7 @@ class SourceManager {
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'); const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const admin = isAdmin; const admin = isAdmin;
const ownerLine = (source) => admin const ownerLine = admin
? `<div class="source-meta hint">${source.ownerId != null ? `<div class="source-meta hint">${source.ownerId != null
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}` ? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
: 'Fuente compartida (todos los usuarios)'}</div>` : 'Fuente compartida (todos los usuarios)'}</div>`
@@ -181,7 +181,7 @@ class SourceManager {
<div class="source-info"> <div class="source-info">
<div class="source-name">${esc(source.name)}</div> <div class="source-name">${esc(source.name)}</div>
<div class="source-url">${esc(source.url)}</div> <div class="source-url">${esc(source.url)}</div>
${ownerLine(source)} ${ownerLine}
</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>
+20 -105
View File
@@ -68,15 +68,6 @@ 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) {
@@ -218,9 +209,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();
} }
@@ -900,43 +890,6 @@ class VideoPlayer {
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot) // Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
await this.stop(); await 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. // 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. // 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
await new Promise(resolve => setTimeout(resolve, 2500)); await new Promise(resolve => setTimeout(resolve, 2500));
@@ -1050,7 +1003,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 +1026,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 +1034,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 +1107,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;
@@ -1297,25 +1238,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());
@@ -1512,10 +1434,6 @@ class VideoPlayer {
* Stop playback * Stop playback
*/ */
async stop() { async 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(); await this.stopTranscodeSession();
@@ -1524,11 +1442,8 @@ class VideoPlayer {
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"
+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);
} }
+15 -19
View File
@@ -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();
@@ -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';
} }
} }
+27 -255
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);
@@ -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() {
@@ -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
+140 -175
View File
@@ -6,160 +6,139 @@
<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>Iniciar sesión — KiraTV</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,77 +146,32 @@
} }
.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">
<path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg>
<h1>KiraTV</h1>
<p id="login-subtitle">Inicia sesión para continuar</p> <p id="login-subtitle">Inicia sesión para continuar</p>
</div> </div>
@@ -248,47 +182,67 @@
<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
View File
@@ -187,7 +187,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) => {
-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;
+6 -8
View File
@@ -15,10 +15,12 @@ const { Readable } = require('stream');
const auth = require('../auth'); const auth = require('../auth');
const { canUserAccessSource } = require('../sourceAccess'); const { canUserAccessSource } = require('../sourceAccess');
// Las rutas de proxy (categorías, streams, EPG, imágenes, HLS, etc.) son accedidas // Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
// directamente por el reproductor y los navegadores sin header Authorization. router.use((req, res, next) => {
// El control de acceso a fuentes se hace en /api/sources (CRUD). const p = req.path || (req.url || '').split('?')[0];
// Por eso NO aplicamos requireAuth globalmente aquí. if (p === '/stream' || p === '/image') return next();
return auth.requireAuth(req, res, next);
});
router.param('sourceId', async (req, res, next, id) => { router.param('sourceId', async (req, res, next, id) => {
try { try {
@@ -31,12 +33,8 @@ router.param('sourceId', async (req, res, next, id) => {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
if (!canUserAccessSource(req.user, source)) { 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' }); return res.status(403).json({ error: 'No access to this source' });
} }
}
req.grantedSource = source; req.grantedSource = source;
next(); next();
} catch (err) { } catch (err) {
+6 -8
View File
@@ -5,7 +5,7 @@ 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 { requireAuth, requireAdmin } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess'); const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
router.use(requireAuth); router.use(requireAuth);
@@ -18,9 +18,8 @@ function sanitizeSourceList(list) {
} }
function parseOwnerId(body) { function parseOwnerId(body) {
const raw = body.ownerId; if (body.ownerId === undefined || body.ownerId === '') return null;
if (raw === undefined || raw === null || raw === '') return null; const n = parseInt(body.ownerId, 10);
const n = parseInt(raw, 10);
return Number.isNaN(n) ? null : n; return Number.isNaN(n) ? null : n;
} }
@@ -162,11 +161,10 @@ router.post('/', async (req, res) => {
} }
let ownerId = null; let ownerId = null;
if (isAdminRole(req.user)) { if (req.user.role === 'admin') {
ownerId = parseOwnerId(req.body); ownerId = parseOwnerId(req.body);
} else { } else {
const uid = req.user.id != null ? Number(req.user.id) : NaN; ownerId = req.user.id;
ownerId = Number.isNaN(uid) ? null : uid;
} }
const source = await sources.create({ type, name, url, username, password, ownerId }); const source = await sources.create({ type, name, url, username, password, ownerId });
@@ -196,7 +194,7 @@ router.put('/:id', async (req, res) => {
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) { if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
updates.ownerId = parseOwnerId(req.body); updates.ownerId = parseOwnerId(req.body);
} }
+61 -11
View File
@@ -5,23 +5,30 @@ 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');
const streamInput = require('../utils/streamInput');
/** /**
* Probe a stream URL with FFprobe to get its total duration in seconds. * 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. * VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
*/ */
function probeDuration(url, ffprobePath, userAgent) { function probeDuration(url, ffprobePath, userAgent) {
return new Promise((resolve) => { return new Promise((resolve) => {
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
const args = [ const args = [
'-v', 'quiet', '-v', 'quiet',
'-print_format', 'json', '-print_format', 'json',
'-show_entries', 'format=duration', '-show_entries', 'format=duration',
'-user_agent', userAgent || 'Mozilla/5.0', '-user_agent', userAgent || 'Mozilla/5.0',
'-probesize', '3000000', '-probesize', sizing.probesize,
'-analyzeduration', '2000000', '-analyzeduration', sizing.analyzeduration,
url
]; ];
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (err, stdout) => { if (referer) {
args.push('-referer', referer);
}
args.push(url);
const timeoutMs = sizing.heavy ? 45000 : 8000;
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
if (err) { resolve(0); return; } if (err) { resolve(0); return; }
try { try {
const data = JSON.parse(stdout); const data = JSON.parse(stdout);
@@ -92,7 +99,41 @@ router.post('/session', async (req, res) => {
]); ]);
// Wait for playlist to be ready (first segments generated) // Wait for playlist to be ready (first segments generated)
const ready = await session.waitForPlaylist(15000); // VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
let ready = await session.waitForPlaylist(waitTimeout);
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
await transcodeSession.removeSession(session.id);
const swSession = await transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto',
upscaleEnabled: false,
videoMode: videoMode,
videoCodec: videoCodec,
audioCodec: audioCodec,
audioChannels: audioChannels
});
await swSession.start();
ready = await swSession.waitForPlaylist(waitTimeout);
if (ready) {
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
return res.json({
sessionId: swSession.id,
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
status: swSession.status,
durationSec: durationSec || 0
});
}
await transcodeSession.removeSession(swSession.id);
}
if (!ready) { if (!ready) {
await transcodeSession.removeSession(session.id); await transcodeSession.removeSession(session.id);
@@ -233,6 +274,9 @@ router.get('/', async (req, res) => {
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`); console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`); console.log(`[Transcode] Using binary: ${ffmpegPath}`);
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
// FFmpeg arguments for transcoding // FFmpeg arguments for transcoding
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3) // Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
// Also works for live streams with ad stitching (Pluto TV, etc.) // Also works for live streams with ad stitching (Pluto TV, etc.)
@@ -240,9 +284,13 @@ router.get('/', async (req, res) => {
'-hide_banner', '-hide_banner',
'-loglevel', 'warning', '-loglevel', 'warning',
'-user_agent', userAgent, '-user_agent', userAgent,
// Faster startup - reduced probe/analyze for quicker first bytes ];
'-probesize', '2000000', // 2MB (reduced from 5MB) if (referer) {
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s) args.push('-referer', referer);
}
args.push(
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
// Error resilience: generate timestamps, discard corrupt packets // Error resilience: generate timestamps, discard corrupt packets
'-fflags', '+genpts+discardcorrupt+nobuffer', '-fflags', '+genpts+discardcorrupt+nobuffer',
// Ignore errors in stream and continue // Ignore errors in stream and continue
@@ -255,7 +303,9 @@ router.get('/', async (req, res) => {
'-reconnect_delay_max', '3', '-reconnect_delay_max', '3',
// Prevent Range/HEAD requests that some providers reject with 405 // Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0', '-seekable', '0',
'-i', url, '-i', url
);
args.push(
// Map only first video and audio stream (avoid subtitle streams causing issues) // Map only first video and audio stream (avoid subtitle streams causing issues)
'-map', '0:v:0', '-map', '0:v:0',
'-map', '0:a:0?', // ? makes audio optional if not present '-map', '0:a:0?', // ? makes audio optional if not present
@@ -276,7 +326,7 @@ router.get('/', async (req, res) => {
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart', '-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
'-flush_packets', '1', // Send data immediately '-flush_packets', '1', // Send data immediately
'-' // Output to stdout '-' // Output to stdout
]; );
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`); console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
@@ -0,0 +1,781 @@
/**
* Transcode Session Service
*
* Manages HLS transcoding sessions with segment caching for VOD seeking.
* Each session transcodes a source URL to HLS segments on disk.
*
* Key features:
* - Session-based transcoding with unique IDs
* - HLS segment output for seeking support
* - Segment caching for fast access
* - Session persistence for recovery after restart
* - Automatic cleanup of stale sessions
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const EventEmitter = require('events');
const hwDetect = require('./hwDetect');
// Session storage
const sessions = new Map();
// Cache directory for transcoded segments
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
/**
* Generate a unique session ID
*/
function generateSessionId() {
return crypto.randomBytes(8).toString('hex');
}
/**
* Ensure cache directory exists
*/
async function ensureCacheDir() {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
/**
* TranscodeSession class
* Manages a single transcoding session from source URL to HLS segments
*/
class TranscodeSession extends EventEmitter {
constructor(url, options = {}) {
super();
this.id = generateSessionId();
this.url = url;
this.dir = path.join(CACHE_DIR, this.id);
this.playlistPath = path.join(this.dir, 'stream.m3u8');
this.process = null;
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
this.status = 'pending'; // pending | starting | running | stopped | error
this.error = null;
this.startTime = Date.now();
this.lastAccess = Date.now();
this.options = {
ffmpegPath: options.ffmpegPath || 'ffmpeg',
userAgent: options.userAgent || 'Mozilla/5.0',
seekOffset: options.seekOffset || 0,
hwEncoder: options.hwEncoder || 'software',
maxResolution: options.maxResolution || '1080p',
quality: options.quality || 'medium',
// Upscaling options
upscaleEnabled: options.upscaleEnabled || false,
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
upscaleTarget: options.upscaleTarget || '1080p',
...options
};
}
/**
* Start the transcoding process
*/
async start() {
if (this.status === 'running') {
return;
}
this.status = 'starting';
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
// Create session directory
try {
await fs.mkdir(this.dir, { recursive: true });
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
// Build FFmpeg arguments for HLS output
const args = this.buildFFmpegArgs();
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
try {
this.process = spawn(this.options.ffmpegPath, args, {
cwd: this.dir,
windowsHide: true
});
this.status = 'running';
// Handle stdout (should be empty for file output)
this.process.stdout.on('data', (data) => {
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
});
// Handle stderr (FFmpeg progress/errors)
let stderrBuffer = '';
this.process.stderr.on('data', (data) => {
stderrBuffer += data.toString();
// Log periodically to avoid spam
const lines = stderrBuffer.split('\n');
if (lines.length > 1) {
lines.slice(0, -1).forEach(line => {
if (line.trim()) {
console.log(`[FFmpeg ${this.id}] ${line}`);
}
});
stderrBuffer = lines[lines.length - 1];
}
});
// Handle process exit
this.process.on('exit', (code) => {
if (code === 0 || code === null) {
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
this.status = 'stopped';
} else if (code !== 255) { // 255 is often from SIGKILL
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
this.status = 'error';
this.error = `FFmpeg exited with code ${code}`;
}
this.process = null;
this.emit('exit', code);
});
// Handle spawn errors
this.process.on('error', (err) => {
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
this.status = 'error';
this.error = err.message;
this.emit('error', err);
});
// Save session metadata
await this.persist();
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
}
/**
* Build FFmpeg arguments for HLS output with optional GPU encoding
*/
buildFFmpegArgs() {
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
const videoMode = this.options.videoMode || 'encode';
// Resolve 'auto' encoder to detected hardware, fallback to software
let encoder = this.options.hwEncoder || 'software';
if (encoder === 'auto') {
const hwCaps = hwDetect.getCapabilities();
encoder = hwCaps?.recommended || 'software';
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
}
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', this.options.userAgent,
];
// Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') {
this.addHwAccelInputArgs(args, encoder);
}
// Input options (common)
args.push(
'-probesize', '5000000',
'-analyzeduration', '5000000',
'-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3'
);
args.push('-i', this.url);
// Add seek offset if specified (as output option to avoid Range requests)
if (this.options.seekOffset > 0) {
args.push('-ss', String(this.options.seekOffset));
}
// Map streams
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');
// Add video encoder and filters based on selected encoder OR copy
if (videoMode === 'copy') {
args.push('-c:v', 'copy');
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
args.push('-bsf:v', 'hevc_mp4toannexb');
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
// Keep Annex-B conversion and force parameter sets to be repeated.
// This helps players recover on streams where SPS/PPS appear sporadically,
// which often manifests as "audio OK, black video".
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
} else {
// Fallback (e.g. unknown codec), try strict extraction
args.push('-bsf:v', 'dump_extra');
}
} else {
this.addVideoEncoderArgs(args, encoder);
}
// Audio: Apply mix preset
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
const audioChannels = this.options.audioChannels || 0;
const audioMixPreset = this.options.audioMixPreset || 'auto';
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
// Define pan filter presets for 5.1 -> Stereo downmix
const AUDIO_MIX_FILTERS = {
// ITU-R BS.775 Standard: Mathematically balanced, transparent
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
};
if (audioMixPreset === 'passthrough') {
// Passthrough: Always copy audio, no processing
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
args.push('-c:a', 'copy');
} else if (audioMixPreset === 'auto' && isStereoAac) {
// Auto + Stereo AAC source: Smart copy
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
args.push('-c:a', 'copy');
} else {
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
args.push(
'-c:a', 'aac',
'-ar', '48000',
'-b:a', '192k',
'-af', `${panFilter},aresample=async=1`
);
}
// HLS output options
args.push(
'-f', 'hls',
'-hls_time', String(SEGMENT_DURATION),
'-hls_list_size', '0', // Keep all segments in playlist
'-hls_flags', 'independent_segments+append_list',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
this.playlistPath
);
return args;
}
/**
* Add hardware acceleration input arguments
*/
addHwAccelInputArgs(args, encoder) {
switch (encoder) {
case 'nvenc':
// NVIDIA CUDA/NVDEC hardware decoding
args.push(
'-hwaccel', 'cuda',
'-hwaccel_output_format', 'cuda'
);
break;
case 'vaapi':
// VAAPI hardware decoding (Linux)
args.push(
'-hwaccel', 'vaapi',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi'
);
break;
case 'qsv':
// Intel QuickSync hardware decoding
args.push(
'-hwaccel', 'qsv',
'-hwaccel_output_format', 'qsv'
);
break;
case 'amf':
// AMD AMF (no hwaccel input, AMF is encode-only)
// Decode on CPU, encode on GPU
break;
case 'software':
case 'auto':
default:
// No hardware acceleration for input
break;
}
}
/**
* Add video encoder arguments based on selected encoder
*/
addVideoEncoderArgs(args, encoder) {
const resolution = this.getTargetHeight();
const quality = this.options.quality || 'medium';
// Quality presets mapping
const qualityPresets = {
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
};
const qp = qualityPresets[quality] || qualityPresets.medium;
switch (encoder) {
case 'nvenc':
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
break;
case 'amf':
this.addAmfEncoderArgs(args, resolution, qp.amf);
break;
case 'vaapi':
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
break;
case 'qsv':
this.addQsvEncoderArgs(args, resolution, qp.qsv);
break;
case 'software':
case 'auto':
default:
this.addSoftwareEncoderArgs(args, resolution, qp.software);
break;
}
}
/**
* Get target height based on maxResolution or upscaleTarget setting
* When upscaling is enabled, uses the upscaleTarget resolution.
* Otherwise, uses maxResolution to cap the output.
*/
getTargetHeight() {
const resolutionMap = {
'4k': 2160,
'1080p': 1080,
'720p': 720,
'480p': 480
};
// When upscaling is enabled, use the upscale target resolution
if (this.options.upscaleEnabled) {
const target = resolutionMap[this.options.upscaleTarget] || 1080;
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
return target;
}
// Otherwise, use max resolution as the cap
return resolutionMap[this.options.maxResolution] || 1080;
}
/**
* Build scale filter string based on encoder and upscaling settings
* @param {string} encoder - The encoder being used
* @param {number} height - Target height
*/
buildScaleFilter(encoder, height) {
const useUpscale = this.options.upscaleEnabled;
const upscaleMethod = this.options.upscaleMethod || 'hardware';
// Log upscaling status
if (useUpscale) {
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
}
// Hardware scaling filters (for both upscale and downscale)
if (upscaleMethod === 'hardware' || !useUpscale) {
switch (encoder) {
case 'nvenc':
// NVIDIA CUDA scaling with Lanczos
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
case 'vaapi':
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
case 'qsv':
return `scale_qsv=w=-2:h=${height}:format=nv12`;
case 'amf':
// AMF uses CPU decode, so use software scale
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
case 'software':
default:
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
}
}
// Software Lanczos scaling (high quality, slower)
return `scale=-2:${height}:flags=lanczos`;
}
/**
* NVIDIA NVENC encoder arguments
*/
addNvencEncoderArgs(args, height, qp) {
// Video filter for scaling on GPU
args.push('-vf', this.buildScaleFilter('nvenc', height));
// NVENC encoder with quality settings
// Using portable options that work across FFmpeg builds
args.push(
'-c:v', 'h264_nvenc',
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
'-rc', 'constqp', // Constant QP mode
'-qp', String(qp),
'-bf', '3' // B-frames for better compression
);
}
/**
* AMD AMF encoder arguments
*/
addAmfEncoderArgs(args, height, qp) {
// CPU decoding + software scale + AMF encode
args.push('-vf', this.buildScaleFilter('amf', height));
args.push(
'-c:v', 'h264_amf',
'-quality', 'quality', // Quality preset
'-rc', 'cqp', // Constant QP
'-qp_i', String(qp),
'-qp_p', String(qp + 2),
'-qp_b', String(qp + 4),
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* VAAPI encoder arguments (Linux)
*/
addVaapiEncoderArgs(args, height, qp) {
// VAAPI filter chain:
// 1. scale_vaapi to resize on GPU
// 2. Ensure output format is nv12 for maximum encoder compatibility
// The format is handled automatically when using -hwaccel_output_format vaapi
args.push('-vf', this.buildScaleFilter('vaapi', height));
// VAAPI encoder with quality setting
// Note: -global_quality is the portable way to set quality for VAAPI
args.push(
'-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility
'-global_quality', String(qp),
'-bf', '3',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* Intel QuickSync encoder arguments
*/
addQsvEncoderArgs(args, height, qp) {
// Scale on QSV
args.push('-vf', this.buildScaleFilter('qsv', height));
args.push(
'-c:v', 'h264_qsv',
'-preset', 'medium',
'-global_quality', String(qp),
'-look_ahead', '1',
'-look_ahead_depth', '40',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* Software encoder arguments (fallback)
*/
addSoftwareEncoderArgs(args, height, crf) {
// Software scaling (use Lanczos for upscaling if enabled)
args.push('-vf', this.buildScaleFilter('software', height));
args.push(
'-c:v', 'libx264',
'-preset', 'veryfast', // Fast for real-time
'-crf', String(crf),
'-profile:v', 'high',
'-level', '4.1',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
);
}
/**
* Stop the transcoding process
*/
stop() {
if (this.process) {
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
this.process.kill('SIGTERM');
// Force kill after 2 seconds if still running
setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
}, 2000);
}
this.status = 'stopped';
}
/**
* Update last access time (prevents cleanup)
*/
touch() {
this.lastAccess = Date.now();
}
/**
* Check if playlist exists and is ready
*/
async isPlaylistReady() {
try {
await fs.access(this.playlistPath);
const content = await fs.readFile(this.playlistPath, 'utf8');
// Check if playlist has at least one segment
return content.includes('.ts');
} catch {
return false;
}
}
/**
* Wait for playlist to be ready (with timeout)
* Fast-fails if FFmpeg exited with an error before the timeout.
*/
async waitForPlaylist(timeoutMs = 10000) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (await this.isPlaylistReady()) {
return true;
}
// Fast-fail: FFmpeg already exited with an error — no point waiting
if (this.status === 'error' && !this.process) {
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
return false;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
return false;
}
/**
* Get the HLS playlist content
*/
async getPlaylist() {
this.touch();
try {
return await fs.readFile(this.playlistPath, 'utf8');
} catch (err) {
return null;
}
}
/**
* Get a specific segment
*/
async getSegment(segmentName) {
this.touch();
const segmentPath = path.join(this.dir, segmentName);
try {
await fs.access(segmentPath);
return segmentPath;
} catch {
return null;
}
}
/**
* Save session metadata to disk for recovery
*/
async persist() {
const metadata = {
id: this.id,
url: this.url,
status: this.status,
startTime: this.startTime,
lastAccess: this.lastAccess,
options: this.options,
seekOffset: this.options.seekOffset
};
const metaPath = path.join(this.dir, 'session.json');
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
}
/**
* Restore a session from disk metadata
*/
static async restore(sessionDir) {
const metaPath = path.join(sessionDir, 'session.json');
try {
const data = await fs.readFile(metaPath, 'utf8');
const metadata = JSON.parse(data);
const session = new TranscodeSession(metadata.url, metadata.options);
session.id = metadata.id;
session.dir = sessionDir;
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
session.startTime = metadata.startTime;
session.lastAccess = metadata.lastAccess;
session.status = 'stopped'; // Not running after restart
return session;
} catch (err) {
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
return null;
}
}
/**
* Delete session directory and all segments
*/
async cleanup() {
this.stop();
try {
await fs.rm(this.dir, { recursive: true, force: true });
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
} catch (err) {
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
}
}
}
/**
* Session Manager
*/
/**
* Create a new transcode session
*/
async function createSession(url, options = {}) {
await ensureCacheDir();
const session = new TranscodeSession(url, options);
sessions.set(session.id, session);
return session;
}
/**
* Get an existing session by ID
*/
function getSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
session.touch();
}
return session;
}
/**
* Get or create a session for a URL (reuses existing if still valid)
*/
async function getOrCreateSession(url, options = {}) {
// Check for existing session with same URL
for (const session of sessions.values()) {
if (session.url === url && session.status === 'running') {
session.touch();
return session;
}
}
// Create new session
return createSession(url, options);
}
/**
* Stop and remove a session
*/
async function removeSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
await session.cleanup();
sessions.delete(sessionId);
}
}
/**
* Cleanup stale sessions (idle for too long)
*/
async function cleanupStaleSessions() {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
await removeSession(id);
}
}
}
/**
* Recover sessions from disk after server restart
*/
async function recoverSessions() {
try {
await fs.access(CACHE_DIR);
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
for (const dirent of dirs) {
if (dirent.isDirectory()) {
const sessionDir = path.join(CACHE_DIR, dirent.name);
const session = await TranscodeSession.restore(sessionDir);
if (session) {
sessions.set(session.id, session);
console.log(`[TranscodeSession] Recovered session ${session.id}`);
}
}
}
} catch (err) {
// Cache dir doesn't exist yet, that's fine
if (err.code !== 'ENOENT') {
console.error('[TranscodeSession] Error recovering sessions:', err.message);
}
}
}
/**
* Start cleanup interval
*/
let cleanupInterval = null;
function startCleanupInterval() {
if (!cleanupInterval) {
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref(); // Don't prevent process exit
}
}
/**
* Get all active sessions (for debugging/monitoring)
*/
function getAllSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
url: s.url,
status: s.status,
startTime: s.startTime,
lastAccess: s.lastAccess,
idleMs: Date.now() - s.lastAccess
}));
}
module.exports = {
TranscodeSession,
createSession,
getSession,
getOrCreateSession,
removeSession,
cleanupStaleSessions,
recoverSessions,
startCleanupInterval,
getAllSessions,
CACHE_DIR,
SEGMENT_DURATION
};
+21 -15
View File
@@ -18,6 +18,7 @@ const fs = require('fs').promises;
const crypto = require('crypto'); const crypto = require('crypto');
const EventEmitter = require('events'); const EventEmitter = require('events');
const hwDetect = require('./hwDetect'); const hwDetect = require('./hwDetect');
const streamInput = require('../utils/streamInput');
// Session storage // Session storage
const sessions = new Map(); const sessions = new Map();
@@ -181,11 +182,17 @@ class TranscodeSession extends EventEmitter {
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
} }
const sizing = streamInput.probeSizingForUrl(this.url);
const referer = streamInput.deriveRefererFromUrl(this.url);
const args = [ const args = [
'-hide_banner', '-hide_banner',
'-loglevel', 'warning', '-loglevel', 'warning',
'-user_agent', this.options.userAgent, '-user_agent', this.options.userAgent,
]; ];
if (referer) {
args.push('-referer', referer);
}
// Add hardware acceleration input options based on encoder (only if encoding) // Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') { if (videoMode === 'encode') {
@@ -194,8 +201,8 @@ class TranscodeSession extends EventEmitter {
// Input options (common) // Input options (common)
args.push( args.push(
'-probesize', '5000000', '-probesize', sizing.probesize,
'-analyzeduration', '5000000', '-analyzeduration', sizing.analyzeduration,
'-fflags', '+genpts+discardcorrupt', '-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err', '-err_detect', 'ignore_err',
'-reconnect', '1', '-reconnect', '1',
@@ -299,12 +306,12 @@ class TranscodeSession extends EventEmitter {
); );
break; break;
case 'vaapi': case 'vaapi':
// VAAPI hardware decoding (Linux) // Solo registramos el dispositivo VAAPI globalmente.
args.push( // Se usa decodificación SOFTWARE para máxima compatibilidad con
'-hwaccel', 'vaapi', // cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
'-hwaccel_device', '/dev/dri/renderD128', // puede no tener soporte de decodificación para todos ellos.
'-hwaccel_output_format', 'vaapi' // El upload a memoria VAAPI se hace en el filtro de vídeo.
); args.push('-vaapi_device', '/dev/dri/renderD128');
break; break;
case 'qsv': case 'qsv':
// Intel QuickSync hardware decoding // Intel QuickSync hardware decoding
@@ -407,7 +414,9 @@ class TranscodeSession extends EventEmitter {
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported") // Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`; return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
case 'vaapi': case 'vaapi':
return `scale_vaapi=w=-2:h=${height}:format=nv12`; // Software decode → scale → nv12 → hwupload → VAAPI encode
// format=nv12 ANTES de hwupload es obligatorio para Intel iGPU en LXC
return `scale=-2:${height},format=nv12,hwupload`;
case 'qsv': case 'qsv':
return `scale_qsv=w=-2:h=${height}:format=nv12`; return `scale_qsv=w=-2:h=${height}:format=nv12`;
case 'amf': case 'amf':
@@ -469,14 +478,11 @@ class TranscodeSession extends EventEmitter {
// The format is handled automatically when using -hwaccel_output_format vaapi // The format is handled automatically when using -hwaccel_output_format vaapi
args.push('-vf', this.buildScaleFilter('vaapi', height)); args.push('-vf', this.buildScaleFilter('vaapi', height));
// VAAPI encoder with quality setting // VAAPI encoder. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
// Note: -global_quality is the portable way to set quality for VAAPI // -bf 3 puede causar problemas en algunos drivers Intel; se omite.
args.push( args.push(
'-c:v', 'h264_vaapi', '-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility '-global_quality', String(qp)
'-global_quality', String(qp),
'-bf', '3',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
); );
} }
+2 -6
View File
@@ -1,5 +1,3 @@
const { isAdminRole } = require('./auth');
/** /**
* Who may use a source for playback and API access. * Who may use a source for playback and API access.
* ownerId null/undefined: legacy shared — any authenticated user may use it. * ownerId null/undefined: legacy shared — any authenticated user may use it.
@@ -7,12 +5,10 @@ const { isAdminRole } = require('./auth');
*/ */
function canUserAccessSource(user, source) { function canUserAccessSource(user, source) {
if (!user || !source) return false; if (!user || !source) return false;
if (isAdminRole(user)) return true; if (user.role === 'admin') return true;
const oid = source.ownerId; const oid = source.ownerId;
if (oid == null || oid === undefined) return true; if (oid == null || oid === undefined) return true;
const uid = user.id != null ? Number(user.id) : NaN; return Number(oid) === Number(user.id);
const oidN = Number(oid);
return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid;
} }
async function getAccessibleSourceIds(req, sourcesApi) { async function getAccessibleSourceIds(req, sourcesApi) {
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/**
* Opciones de entrada HTTP para ffprobe/ffmpeg con orígenes tipo Xtream / Jellyfin (Dispatcharr).
* Muchos backends exigen Referer/origin coherentes además del User-Agent.
*/
function deriveRefererFromUrl(inputUrl) {
try {
const u = new URL(inputUrl);
return `${u.origin}/`;
} catch {
return null;
}
}
/** VOD pesado (Remux Jellyfin MKV/MP4, GOP largo) — probes más grandes. */
function isHeavyVodUrl(url) {
if (!url || typeof url !== 'string') return false;
const u = url.toLowerCase();
if (u.includes('/movie/') || u.includes('/series/')) return true;
if (/\.mkv\b/i.test(url) || /\.mp4\b/i.test(url)) return true;
return false;
}
function probeSizingForUrl(url) {
if (isHeavyVodUrl(url)) {
/** Microseconds; VOD Jellyfin/remux pueden necesitar leer bastante antes del analyze */
return { probesize: '32000000', analyzeduration: '45000000', heavy: true };
}
return { probesize: '8000000', analyzeduration: '8000000', heavy: false };
}
module.exports = {
deriveRefererFromUrl,
isHeavyVodUrl,
probeSizingForUrl
};