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
15 changed files with 1024 additions and 635 deletions
+109 -42
View File
@@ -1,5 +1,5 @@
/* =====================================================
* NodeCast TV - Dark Theme Design System
* KiraTV — tema oscuro (azul marino + oro)
===================================================== */
/* CSS Variables */
@@ -7,32 +7,37 @@
/* iOS Safari toolbar compensation (set dynamically by JS) */
--ios-ui-bottom: 0px;
/* Brand — logo hero (marino #000066 / blanco; acento arena para botones) */
--color-brand-navy: #000066;
--color-brand-navy-mid: #050045;
--color-accent: #e8d5b5;
--color-accent-hover: #f5ead8;
--color-accent-muted: rgba(232, 213, 181, 0.14);
--color-on-accent: #000044;
/* Colors */
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-tertiary: #1a1a25;
--color-bg-hover: #22222f;
--color-bg-active: #2a2a3a;
--color-bg-primary: #000022;
--color-bg-secondary: #050038;
--color-bg-tertiary: #0a0a4d;
--color-bg-hover: #12125c;
--color-bg-active: #1a1a72;
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-dim: rgba(99, 102, 241, 0.2);
--color-accent-dim: var(--color-accent-muted);
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-text-primary: #f1f1f5;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
--color-text-primary: #f4f6fb;
--color-text-secondary: #a8b0c4;
--color-text-muted: #6b7389;
--color-border: #27272a;
--color-border-light: #3f3f46;
--color-border: #243047;
--color-border-light: #344062;
/* Glass effect */
--glass-bg: rgba(18, 18, 26, 0.8);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-bg: rgba(0, 0, 102, 0.88);
--glass-border: rgba(255, 255, 255, 0.08);
/* Spacing */
--space-xs: 4px;
@@ -52,7 +57,7 @@
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
--shadow-glow: 0 4px 28px rgba(232, 213, 181, 0.16);
/* Transitions */
--transition-fast: 150ms ease;
@@ -83,11 +88,14 @@
html,
body {
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(ellipse 120% 85% at 50% -30%, rgba(255, 255, 255, 0.04), transparent 52%),
radial-gradient(ellipse 90% 55% at 100% 35%, rgba(0, 0, 102, 0.45), transparent),
linear-gradient(185deg, var(--color-brand-navy) 0%, var(--color-bg-primary) 55%);
overflow: hidden;
/* Prevent pull-to-refresh and overscroll bounce on mobile */
overscroll-behavior: none;
@@ -137,6 +145,9 @@ h6 {
h2 {
font-size: 1.5rem;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
h3 {
@@ -168,8 +179,9 @@ h4 {
padding-left: max(var(--space-lg), var(--safe-area-inset-left));
padding-right: max(var(--space-lg), var(--safe-area-inset-right));
background: var(--glass-bg);
backdrop-filter: blur(12px);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--glass-border);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06);
z-index: 100;
}
@@ -190,12 +202,24 @@ h4 {
fill: var(--color-accent);
}
/* Logo imagen de marca (PNG/JPEG) */
.navbar-logo-img {
display: block;
height: 42px;
width: auto;
max-width: 172px;
object-fit: contain;
object-position: left center;
border-radius: 10px;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.brand-text {
font-family: 'Century Gothic', sans-serif;
font-size: 1.5rem;
font-weight: 500;
letter-spacing: -0.03em;
color: #fff;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-size: 1.45rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--color-text-primary);
/* Visual correction: shift text slightly down to align optically with icon center */
padding-top: 2px;
}
@@ -207,8 +231,8 @@ h4 {
.version-badge {
font-size: 0.65rem;
font-weight: 600;
background: var(--color-accent);
color: #fff;
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
@@ -1492,13 +1516,15 @@ kbd {
}
.btn-primary {
background: var(--color-accent);
color: white;
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
font-weight: 600;
}
.btn-primary:hover {
background: var(--color-accent-hover);
background: linear-gradient(135deg, var(--color-accent-hover) 0%, var(--color-accent) 100%);
box-shadow: var(--shadow-glow);
color: var(--color-on-accent);
}
.btn-secondary {
@@ -4415,9 +4441,9 @@ kbd {
}
.user-badge-sso {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
box-shadow: 0 2px 4px rgba(232, 213, 181, 0.22);
}
.user-badge-local {
@@ -4448,24 +4474,24 @@ kbd {
gap: 8px;
padding: 6px 14px;
margin-right: var(--space-md);
background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%);
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
border-radius: var(--radius-full);
color: white;
color: var(--color-on-accent);
text-decoration: none;
font-size: 13px;
font-weight: 500;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
box-shadow: 0 2px 10px rgba(232, 213, 181, 0.22);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
animation: now-playing-pulse 2s ease-in-out infinite;
}
.now-playing-indicator:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
box-shadow: 0 4px 14px rgba(232, 213, 181, 0.28);
}
.now-playing-indicator.hidden {
@@ -4819,4 +4845,45 @@ kbd {
font-size: 0.7rem;
max-width: 70px;
}
}
}
/* Electron Windows: mpv embutido (HWND) — ocultar overlays tras el vídeo nativo */
body.kiratv-win-embedded #player-controls-overlay,
body.kiratv-win-embedded #watch-overlay {
visibility: hidden !important;
}
/* WebView2 + panel vídeo Win32 — lista estrecha, reproductor fuera del motor web */
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .player-section { display: none !important; }
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .home-layout {
display: block;
}
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .channel-sidebar {
width: 100% !important;
max-width: none !important;
}
body.kiratv-shell-wv2-playing #player-controls-overlay,
body.kiratv-shell-wv2-playing #watch-overlay {
visibility: hidden !important;
}
/* App de escritorio (Electron / WebView2): ancho completo; quita cajas centradas tipo max-width:1400px */
body.kiratv-desktop-app #app {
max-width: none;
width: 100%;
margin: 0;
}
body.kiratv-desktop-app .dashboard-content {
max-width: none !important;
width: 100%;
margin-left: 0 !important;
margin-right: 0 !important;
}
body.kiratv-desktop-app .settings-container {
max-width: none !important;
}
body.kiratv-desktop-app .watch-details-section {
max-width: none !important;
}
+159 -163
View File
@@ -10,11 +10,11 @@
<title>KiraTV | Televisión en el navegador</title>
<meta name="description" content="TV en directo, películas y series con guía EPG — KiraTV">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link rel="apple-touch-icon" href="/img/kiratv-logo-hero.jpg">
<link rel="stylesheet" href="css/main.css?v=kira6">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Montserrat:wght@500;600;700;800&display=swap" rel="stylesheet">
<!-- HLS.js for video playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
</head>
@@ -24,15 +24,10 @@
<!-- Navigation -->
<nav class="navbar">
<div class="navbar-brand">
<span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6" fill="#6366f1"
class="icon">
<path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg></span>
<span class="brand-text">KiraTV</span>
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV" class="navbar-logo-img" width="176" height="44" decoding="async">
<span id="version-badge" class="version-badge"></span>
</div>
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu">
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir o cerrar menú">
<span></span>
<span></span>
<span></span>
@@ -41,14 +36,14 @@
<!-- Now Playing indicator for movies/series -->
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
<span class="now-playing-icon">▶</span>
<span class="now-playing-text" id="now-playing-text">Now Playing</span>
<span class="now-playing-text" id="now-playing-text">Reproduciendo</span>
</a>
<a href="#" class="nav-link active" data-page="home">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg></span>
<span>Home</span>
<span>Inicio</span>
</a>
<a href="#" class="nav-link" data-page="live">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -56,14 +51,14 @@
<path
d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" />
</svg></span>
<span>Live TV</span>
<span>TV en directo</span>
</a>
<a href="#" class="nav-link" data-page="guide">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg></span>
<span>TV Guide</span>
<span>Guía TV</span>
</a>
<a href="#" class="nav-link" data-page="movies">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -71,7 +66,7 @@
<path
d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z" />
</svg></span>
<span>Movies</span>
<span>Películas</span>
</a>
<a href="#" class="nav-link" data-page="series">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -87,7 +82,7 @@
<path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg></span>
<span>Settings</span>
<span>Ajustes</span>
</a>
</div>
</nav>
@@ -97,7 +92,7 @@
<!-- Home (Dashboard) Page -->
<div id="page-home" class="page active">
<!-- Content will be rendered by HomePage.js -->
<div class="loading-state">Loading Dashboard...</div>
<div class="loading-state">Cargando inicio…</div>
</div>
<!-- Live TV Page (formerly Home) -->
@@ -108,14 +103,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
<span>Channels</span>
<span>Canales</span>
</button>
<!-- Overlay for drawer -->
<div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div>
<!-- Expand button (visible when sidebar collapsed on desktop) -->
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Expand sidebar">
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Mostrar panel de canales">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg>
@@ -126,11 +121,11 @@
<div class="sidebar-header">
<div class="sidebar-header-row">
<div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="channel-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Collapse sidebar">
title="Ocultar panel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
@@ -138,9 +133,9 @@
</div>
<div class="sidebar-controls">
<select id="source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expand/Collapse All">
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expandir o plegar todo">
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" />
@@ -151,8 +146,8 @@
</div>
<div id="channel-list" class="channel-list">
<div class="empty-state">
<p>No channels loaded</p>
<p class="hint">Add a source in Settings to get started</p>
<p>Cargando canales…</p>
<p class="hint">Las categorías vienen plegadas: pulsa un nombre de grupo para ver los canales. Puedes usar «Expandir/plegar todo» arriba.</p>
</div>
</div>
</aside>
@@ -225,9 +220,9 @@
</svg>
</button>
<div class="watch-captions-menu hidden" id="player-captions-menu">
<div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-title">Subtítulos</div>
<div class="captions-menu-list" id="player-captions-list">
<button class="captions-option active" data-index="-1">Off</button>
<button class="captions-option active" data-index="-1">Desactivados</button>
</div>
</div>
</div>
@@ -257,7 +252,7 @@
<path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
Copy Stream URL
Copiar URL del stream
</button>
</div>
</div>
@@ -267,18 +262,18 @@
<div class="player-overlay" id="player-overlay">
<div class="overlay-content">
<p>Select a channel to start watching</p>
<p>Elige un canal para empezar</p>
</div>
</div>
<!-- Now Playing Overlay -->
<div class="now-playing-overlay hidden" id="now-playing">
<div class="now-playing-info">
<h3 class="channel-name">No channel selected</h3>
<h3 class="channel-name">Ningún canal seleccionado</h3>
<p class="program-title"></p>
<p class="program-time"></p>
</div>
<div class="up-next">
<h4>Up Next</h4>
<h4>A continuación</h4>
<ul id="up-next-list"></ul>
</div>
</div>
@@ -290,24 +285,24 @@
<!-- Guide Page -->
<div id="page-guide" class="page">
<div class="guide-header">
<h2>TV Guide</h2>
<h2>Guía TV</h2>
<div class="guide-controls">
<select id="epg-group-select" class="source-select">
<option value="">All Groups</option>
<option value="">Todos los grupos</option>
</select>
<div class="search-wrapper">
<input type="text" id="epg-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="epg-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="guide-prev" class="btn btn-ghost">&larr; Earlier</button>
<span id="guide-date" class="guide-date">Today</span>
<button id="guide-next" class="btn btn-ghost">Later &rarr;</button>
<button id="guide-prev" class="btn btn-ghost">&larr; Antes</button>
<span id="guide-date" class="guide-date">Hoy</span>
<button id="guide-next" class="btn btn-ghost">Después &rarr;</button>
</div>
</div>
<div id="epg-grid" class="epg-grid">
<div class="empty-state">
<p>No EPG data available</p>
<p class="hint">Add an EPG source in Settings</p>
<p>No hay datos de guía (EPG)</p>
<p class="hint">Añade una fuente EPG en Ajustes</p>
</div>
</div>
</div>
@@ -315,29 +310,29 @@
<!-- Movies Page -->
<div id="page-movies" class="page">
<div class="movies-header">
<h2>Movies</h2>
<h2>Películas</h2>
<div class="movies-controls">
<select id="movies-source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<select id="movies-category-select" class="source-select">
<option value="">All Categories</option>
<option value="">Todas las categorías</option>
</select>
<div class="search-wrapper">
<input type="text" id="movies-search" placeholder="Search movies..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="movies-search" placeholder="Buscar películas…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favorites
</svg> Favoritos
</button>
</div>
</div>
<div id="movies-grid" class="movies-grid">
<div class="empty-state">
<p>Loading movies...</p>
<p>Cargando películas…</p>
</div>
</div>
</div>
@@ -348,20 +343,20 @@
<h2>Series</h2>
<div class="series-controls">
<select id="series-source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<select id="series-category-select" class="source-select">
<option value="">All Categories</option>
<option value="">Todas las categorías</option>
</select>
<div class="search-wrapper">
<input type="text" id="series-search" placeholder="Search series..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="series-search" placeholder="Buscar series…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favorites
</svg> Favoritos
</button>
</div>
</div>
@@ -369,12 +364,12 @@
<div class="series-content">
<div id="series-grid" class="series-grid">
<div class="empty-state">
<p>Loading series...</p>
<p>Cargando series…</p>
</div>
</div>
<!-- Series details panel (seasons/episodes) -->
<div id="series-details" class="series-details hidden">
<button class="series-back-btn">← Back to Series</button>
<button class="series-back-btn">← Volver a series</button>
<div class="series-details-header">
<img id="series-poster" src="" alt="" class="series-poster">
<div class="series-info">
@@ -390,59 +385,59 @@
<!-- Settings Page -->
<div id="page-settings" class="page">
<div class="settings-container">
<h2>Settings</h2>
<h2>Ajustes</h2>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="sources">Sources</button>
<button class="tab" data-tab="player">Player</button>
<button class="tab" data-tab="transcode">Transcoding</button>
<button class="tab" data-tab="content">Manage Content</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
<button class="tab active" data-tab="sources">Fuentes</button>
<button class="tab" data-tab="player">Reproductor</button>
<button class="tab" data-tab="transcode">Transcodificación</button>
<button class="tab" data-tab="content">Contenido</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Usuarios</button>
</div>
<!-- Sources Tab -->
<div id="tab-sources" class="tab-content active">
<div class="source-section">
<div class="section-header">
<h3>Xtream Connections</h3>
<button class="btn btn-primary" id="add-xtream">+ Add Xtream</button>
<h3>Conexiones Xtream</h3>
<button class="btn btn-primary" id="add-xtream">+ Añadir Xtream</button>
</div>
<div id="xtream-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>M3U Playlists</h3>
<button class="btn btn-primary" id="add-m3u">+ Add M3U</button>
<h3>Listas M3U</h3>
<button class="btn btn-primary" id="add-m3u">+ Añadir M3U</button>
</div>
<div id="m3u-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>EPG Sources</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
<h3>Fuentes EPG</h3>
<button class="btn btn-primary" id="add-epg">+ Añadir EPG</button>
</div>
<div id="epg-list" class="source-list"></div>
</div>
<div class="source-section">
<h3>EPG Data Settings</h3>
<h3>Datos EPG</h3>
<div class="setting-item">
<label for="epg-refresh-interval">Auto-refresh EPG data every:</label>
<label for="epg-refresh-interval">Actualizar datos EPG automáticamente cada:</label>
<select id="epg-refresh-interval" class="form-input">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="12">12 hours</option>
<option value="24" selected>24 hours</option>
<option value="0">Manual only</option>
<option value="1">1 hora</option>
<option value="2">2 horas</option>
<option value="4">4 horas</option>
<option value="12">12 horas</option>
<option value="24" selected>24 horas</option>
<option value="0">Solo manual</option>
</select>
</div>
<div class="setting-item">
<span class="setting-label">Last Refreshed:</span>
<span id="epg-last-refreshed" class="hint">Never</span>
<span class="setting-label">Última actualización:</span>
<span id="epg-last-refreshed" class="hint">Nunca</span>
</div>
<p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon" style="width: 1em; height: 1em; vertical-align: middle;">
@@ -455,32 +450,32 @@
<!-- Player Tab -->
<div id="tab-player" class="tab-content">
<div class="settings-section">
<h3>Keyboard Controls</h3>
<h3>Teclado</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-arrow-keys" checked>
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Arrow keys change channels</span>
<span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span>
<span class="setting-label">Las flechas cambian de canal</span>
<span class="setting-hint">Si está activo: ↑/↓ cambian canal y ←/→ ajustan volumen</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Now Playing Overlay</h3>
<h3>Overlay «Reproduciendo»</h3>
<div class="setting-item">
<label for="setting-overlay-duration">Display Duration (seconds)</label>
<label for="setting-overlay-duration">Duración en pantalla (segundos)</label>
<input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
value="5">
</div>
</div>
<div class="settings-section">
<h3>Volume Settings</h3>
<h3>Volumen</h3>
<div class="setting-item">
<label for="setting-default-volume">Default Volume</label>
<label for="setting-default-volume">Volumen por defecto</label>
<div class="volume-slider-container">
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span>
@@ -492,36 +487,36 @@
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Remember Last Volume</span>
<span class="setting-hint">When enabled, volume persists between sessions</span>
<span class="setting-label">Recordar último volumen</span>
<span class="setting-hint">Si está activo, el volumen se conserva entre sesiones</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Playback</h3>
<h3>Reproducción</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-autoplay-next">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Auto-play Next Episode</span>
<span class="setting-hint">Automatically play the next episode when one ends</span>
<span class="setting-label">Autoplay del siguiente episodio</span>
<span class="setting-hint">Cuando termine un episodio, pasa automáticamente al siguiente</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<h3>Atajos de teclado</h3>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Canal anterior/siguiente (o volumen)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Bajar/subir volumen (si flechas = canales)</div>
<div class="shortcut"><kbd>Espacio</kbd> / <kbd>K</kbd> Reproducir / Pausa</div>
<div class="shortcut"><kbd>F</kbd> Pantalla completa</div>
<div class="shortcut"><kbd>M</kbd> Silenciar / activar sonido</div>
<div class="shortcut"><kbd>I</kbd> Mostrar u ocultar información</div>
<div class="shortcut"><kbd>Re Pág</kbd> / <kbd>Av Pág</kbd> Canal anterior/siguiente</div>
</div>
</div>
</div>
@@ -529,14 +524,14 @@
<!-- Transcoding Tab -->
<div id="tab-transcode" class="tab-content">
<div class="settings-section">
<h3>Detected Hardware</h3>
<h3>Hardware detectado</h3>
<div id="hw-info-container" class="hw-info-container">
<p class="hint">Loading hardware information...</p>
<p class="hint">Cargando información del hardware…</p>
</div>
</div>
<div class="settings-section">
<h3>Encoder Settings</h3>
<h3>Codificador</h3>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Hardware Encoder</span>
@@ -678,12 +673,12 @@
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Stream Output Format</span>
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
issues.</span>
<span class="setting-hint">Para Dispatcharr: elige <strong>TS</strong> (aunque la URL lleve .m3u8, suele
ser video MPEG-TS; HLS falla en el navegador).</span>
</div>
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;">
<option value="m3u8">HLS (m3u8) - Recommended</option>
<option value="ts">MPEG-TS (ts) - Better buffer</option>
<option value="ts">MPEG-TS (ts) — recomendado con Dispatcharr</option>
<option value="m3u8">HLS (m3u8) — solo si el proveedor es manifiesto real</option>
</select>
</div>
</div>
@@ -734,30 +729,30 @@
<div class="content-browser">
<div class="content-browser-header">
<div class="content-type-toggle">
<button class="btn btn-sm active" id="content-type-channels">Channels</button>
<button class="btn btn-sm" id="content-type-movies">Movies</button>
<button class="btn btn-sm active" id="content-type-channels">Canales</button>
<button class="btn btn-sm" id="content-type-movies">Películas</button>
<button class="btn btn-sm" id="content-type-series">Series</button>
</div>
<select id="content-source-select" class="source-select">
<option value="">Select a source...</option>
<option value="">Selecciona una fuente…</option>
</select>
<div class="search-wrapper">
<input type="text" id="content-search" placeholder="Search groups/channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="content-search" placeholder="Buscar grupos o canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<div class="content-actions">
<button class="btn btn-sm btn-secondary" id="content-show-all">Show All</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</button>
<button class="btn btn-sm btn-secondary" id="content-show-all">Mostrar todo</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Ocultar todo</button>
<button class="btn btn-sm btn-primary" id="content-save"><svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z" />
</svg> Save Changes</button>
</svg> Guardar cambios</button>
</div>
</div>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Checked = Visible, ☐ Unchecked = Hidden</p>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Marcado = visible · ☐ Sin marcar = oculto</p>
<div id="content-tree" class="content-tree">
<p class="hint">Select a source to view groups and channels</p>
<p class="hint">Selecciona una fuente para ver grupos y canales</p>
</div>
</div>
</div>
@@ -765,47 +760,47 @@
<!-- Users Tab (Admin Only) -->
<div id="tab-users" class="tab-content">
<div class="settings-section">
<h3>User Management</h3>
<p class="hint">Manage user accounts and permissions</p>
<h3>Usuarios</h3>
<p class="hint">Gestiona cuentas y permisos</p>
<div class="user-list-container">
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
<th>Usuario</th>
<th>Correo</th>
<th>Rol</th>
<th>Creado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="user-list">
<tr>
<td colspan="4" class="hint">Loading users...</td>
<td colspan="4" class="hint">Cargando usuarios…</td>
</tr>
</tbody>
</table>
</div>
<div class="add-user-section">
<h4>Add New User</h4>
<h4>Añadir usuario</h4>
<form id="add-user-form" class="user-form">
<div class="form-group">
<label for="new-username">Username</label>
<label for="new-username">Usuario</label>
<input type="text" id="new-username" name="username" class="form-input" required>
</div>
<div class="form-group">
<label for="new-password">Password</label>
<label for="new-password">Contraseña</label>
<input type="password" id="new-password" name="password" class="form-input" required minlength="6">
</div>
<div class="form-group">
<label for="new-role">Role</label>
<label for="new-role">Rol</label>
<select id="new-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<option value="viewer">Espectador</option>
<option value="admin">Administrador</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
<button type="submit" class="btn btn-primary">Añadir usuario</button>
</form>
</div>
</div>
@@ -823,7 +818,7 @@
<div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title -->
<div class="watch-top-bar vod-header">
<button class="watch-back-btn" id="watch-back-btn" title="Back">
<button class="watch-back-btn" id="watch-back-btn" title="Volver">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
@@ -901,9 +896,9 @@
</svg>
</button>
<div class="watch-captions-menu hidden" id="watch-captions-menu">
<div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-title">Subtítulos</div>
<div class="captions-menu-list" id="watch-captions-list">
<button class="captions-option active" data-index="-1">Off</button>
<button class="captions-option active" data-index="-1">Desactivados</button>
</div>
</div>
</div>
@@ -932,7 +927,7 @@
<path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
Copy Stream URL
Copiar URL del stream
</button>
</div>
</div>
@@ -941,7 +936,7 @@
<!-- Scroll Indicator -->
<div class="watch-scroll-hint" id="watch-scroll-hint">
<span>Scroll for details</span>
<span>Desplázate para ver detalles</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg>
@@ -951,12 +946,12 @@
<!-- Next Episode Panel -->
<div class="watch-next-episode hidden" id="watch-next-episode">
<div class="next-info">
<p class="next-label">Up Next</p>
<p class="next-label">Siguiente</p>
<h4 id="next-episode-title"></h4>
</div>
<span class="next-countdown" id="next-countdown">10</span>
<button class="btn btn-primary btn-sm" id="next-play-now">Play Now</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="next-play-now">Reproducir ya</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancelar</button>
</div>
</section>
@@ -978,7 +973,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg>
<span id="watch-play-btn-text">Play</span>
<span id="watch-play-btn-text">Reproducir</span>
</button>
<button class="btn btn-ghost" id="watch-favorite-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -991,7 +986,7 @@
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<span>Favorite</span>
<span>Favorito</span>
</button>
</div>
</div>
@@ -999,7 +994,7 @@
<!-- Movies: Recommended Section -->
<div class="watch-recommended hidden" id="watch-recommended">
<h3>More Like This</h3>
<h3>Te puede gustar</h3>
<div class="watch-recommended-grid" id="watch-recommended-grid">
<!-- Movie cards inserted dynamically -->
</div>
@@ -1007,7 +1002,7 @@
<!-- Series: Seasons & Episodes -->
<div class="watch-episodes hidden" id="watch-episodes">
<h3>Episodes</h3>
<h3>Episodios</h3>
<div class="watch-seasons" id="watch-seasons">
<!-- Season accordions inserted dynamically -->
</div>
@@ -1022,7 +1017,7 @@
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Modal</h3>
<h3 id="modal-title">Ventana</h3>
<button class="modal-close">&times;</button>
</div>
<div id="modal-body" class="modal-body"></div>
@@ -1035,19 +1030,19 @@
<button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg> Play</button>
</svg> Reproducir</button>
<button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon">
<path
d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
</svg> Hide</button>
</svg> Ocultar</button>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" id="edit-user-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit User</h3>
<h3 class="modal-title">Editar usuario</h3>
<button class="modal-close" id="edit-user-close">&times;</button>
</div>
<div class="modal-body">
@@ -1055,41 +1050,41 @@
<input type="hidden" id="edit-user-id">
<div class="modal-form-group">
<label for="edit-username">Username</label>
<label for="edit-username">Usuario</label>
<input type="text" id="edit-username" name="username" class="form-input" required>
</div>
<div class="modal-form-group">
<label for="edit-email">Email</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated">
<label for="edit-email">Correo</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="Sin correo asociado">
</div>
<div class="modal-form-group">
<label for="edit-role">Role</label>
<label for="edit-role">Rol</label>
<select id="edit-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<option value="viewer">Espectador</option>
<option value="admin">Administrador</option>
</select>
</div>
<div class="modal-form-group">
<label for="edit-password">New Password</label>
<label for="edit-password">Nueva contraseña</label>
<input type="password" id="edit-password" name="password" class="form-input"
placeholder="Leave blank to keep current">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged.
placeholder="Dejar en blanco para no cambiar">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Opcional. Déjalo en blanco para mantener la actual.
</p>
</div>
<div class="modal-form-group hidden" id="oidc-info-group">
<label>OIDC Provider ID</label>
<label>ID de proveedor OIDC</label>
<code id="edit-oidc-id"
style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
<button class="btn btn-primary" id="edit-user-save">Save Changes</button>
<button class="btn btn-ghost" id="edit-user-cancel">Cancelar</button>
<button class="btn btn-primary" id="edit-user-save">Guardar cambios</button>
</div>
</div>
</div>
@@ -1097,7 +1092,8 @@
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/components/VideoPlayer.js?v=2"></script>
<script src="/js/desktopPlayback.js?v=9"></script>
<script src="/js/components/VideoPlayer.js?v=6"></script>
<script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
@@ -1106,9 +1102,9 @@
<script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
<script src="/js/pages/Settings.js?v=3"></script>
<script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/app.js?v=5"></script>
<script>
// Check for SSO token in URL
(function () {
+1 -1
View File
@@ -136,7 +136,7 @@ const API = {
seriesInfo: (sourceId, seriesId) =>
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') =>
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
},
+5
View File
@@ -150,6 +150,11 @@ class App {
// Initialize home page first (it's needed for channel list)
await this.pages.home.init();
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
await this.channelList.loadSources();
await this.channelList.loadChannels();
// Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => {
+1 -1
View File
@@ -1159,7 +1159,7 @@ class ChannelList {
let streamUrl;
if (channel.sourceType === 'xtream') {
// Get stream format from player settings (server-side) or fallback
const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
const streamFormat = window.app?.player?.settings?.streamFormat || 'ts';
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
streamUrl = result.url;
} else {
+99 -17
View File
@@ -8,6 +8,8 @@ class SourceManager {
this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list');
/** @type {Record<number, string>} id → username (admin, for labels) */
this.userNamesById = {};
// Content browser state
this.contentType = 'channels'; // 'channels' or 'movies'
@@ -20,6 +22,29 @@ class SourceManager {
this.init();
}
/**
* Comprueba si la sesión es admin. Usa /api/auth/me si window.app.currentUser
* aún no está (carrera: el modal se abre antes de que termine checkAuth).
*/
async isAdminSession() {
let role = window.app?.currentUser?.role;
if (!role && typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) {
try {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (r.ok) {
const u = await r.json();
role = u.role;
}
} catch (_) { /* ignore */ }
}
}
return String(role || '').toLowerCase() === 'admin';
}
init() {
// Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
@@ -112,9 +137,20 @@ class SourceManager {
try {
const sources = await API.sources.getAll();
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
const admin = await this.isAdminSession();
this.userNamesById = {};
if (admin) {
try {
const users = await API.users.getAll();
users.forEach(u => { this.userNamesById[u.id] = u.username; });
} catch (e) {
console.warn('[SourceManager] Could not load users for labels:', e);
}
}
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream', admin);
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u', admin);
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg', admin);
} catch (err) {
console.error('Error loading sources:', err);
}
@@ -123,7 +159,7 @@ class SourceManager {
/**
* Render source list
*/
renderSourceList(container, sources, type) {
renderSourceList(container, sources, type, isAdmin = false) {
if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return;
@@ -131,12 +167,21 @@ class SourceManager {
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series };
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const admin = isAdmin;
const ownerLine = admin
? `<div class="source-meta hint">${source.ownerId != null
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
: 'Fuente compartida (todos los usuarios)'}</div>`
: '';
container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span>
<div class="source-info">
<div class="source-name">${source.name}</div>
<div class="source-url">${source.url}</div>
<div class="source-name">${esc(source.name)}</div>
<div class="source-url">${esc(source.url)}</div>
${ownerLine}
</div>
<div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
@@ -165,7 +210,7 @@ class SourceManager {
/**
* Show add source modal
*/
showAddModal(type) {
async showAddModal(type) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
@@ -174,7 +219,7 @@ class SourceManager {
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type];
body.innerHTML = this.getSourceForm(type);
body.innerHTML = await this.getSourceForm(type);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -202,7 +247,7 @@ class SourceManager {
const footer = document.getElementById('modal-footer');
title.textContent = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = this.getSourceForm(type, source);
body.innerHTML = await this.getSourceForm(type, source);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -222,11 +267,32 @@ class SourceManager {
/**
* Get source form HTML
*/
getSourceForm(type, source = {}) {
async getSourceForm(type, source = {}) {
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
let ownerField = '';
if (await this.isAdminSession()) {
let users = [];
try {
users = await API.users.getAll();
} catch (e) {
console.warn('[SourceManager] users list:', e);
}
const opts = [`<option value="">Compartida (todos los usuarios autenticados)</option>`].concat(
users.map(u => `<option value="${u.id}" ${Number(source.ownerId) === Number(u.id) ? 'selected' : ''}>${esc(u.username)}</option>`)
);
ownerField = `
<div class="form-group">
<label for="source-owner">Propietario</label>
<select id="source-owner" class="form-input">${opts.join('')}</select>
<p class="hint">Si asignas un usuario, solo él podrá usar esta fuente en el proxy (evita doble consumo de conexiones XC).</p>
</div>`;
}
const nameField = `
<div class="form-group">
<label for="source-name">Name</label>
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${esc(source.name)}">
</div>
`;
@@ -235,27 +301,28 @@ class SourceManager {
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${source.url || ''}">
value="${esc(source.url)}">
</div>
`;
if (type === 'xtream') {
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
return `
${ownerField}
${nameField}
${urlField}
<div class="form-group">
<label for="source-username">Username</label>
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
<input type="text" id="source-username" class="form-input" value="${esc(source.username)}">
</div>
<div class="form-group">
<label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input"
value="${source.password && !source.password.includes('•') ? source.password : ''}">
<input type="password" id="source-password" class="form-input" value="${pwVal}">
</div>
`;
}
return nameField + urlField;
return ownerField + nameField + urlField;
}
/**
@@ -295,7 +362,15 @@ class SourceManager {
}
}
await API.sources.create({ type, name, url, username, password });
const payload = { type, name, url, username, password };
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
payload.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.create(payload);
document.getElementById('modal').classList.remove('active');
await this.loadSources();
@@ -329,6 +404,13 @@ class SourceManager {
data.username = username;
if (password) data.password = password;
}
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
data.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active');
+325
View File
@@ -0,0 +1,325 @@
/**
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
*/
(function () {
function showToast(msg, type, durationMs) {
let container = document.getElementById('kiratv-desktop-toast');
if (!container) {
container = document.createElement('div');
container.id = 'kiratv-desktop-toast';
Object.assign(container.style, {
position: 'fixed',
bottom: '24px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
pointerEvents: 'none'
});
document.body.appendChild(container);
}
const toast = document.createElement('div');
Object.assign(toast.style, {
background: type === 'error' ? '#c0392b' : '#1a7f4a',
color: '#fff',
padding: '10px 20px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'sans-serif',
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
opacity: '1',
transition: 'opacity 0.4s'
});
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 450);
}, durationMs || 3500);
}
function unwrapDesktopWrapperUrl(urlLike) {
try {
const u = new URL(String(urlLike), window.location.origin);
const wrappedPath = u.pathname;
if (
wrappedPath === '/api/transcode' ||
wrappedPath === '/api/remux' ||
wrappedPath === '/api/proxy/stream'
) {
const original = u.searchParams.get('url');
if (original) return original;
}
} catch (_) {
/* */
}
return urlLike;
}
function absolutePlaybackUrl(streamUrl) {
if (!streamUrl) return '';
const raw = String(streamUrl).trim();
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
if (/^https?:\/\//i.test(s)) return s;
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
}
async function resolveNativePlaybackUrl(streamUrl) {
const abs = absolutePlaybackUrl(streamUrl);
try {
const u = new URL(abs);
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
const r = await fetch(abs, { credentials: 'include' });
if (r.ok) {
const j = await r.json();
if (j && typeof j.url === 'string' && j.url.trim()) {
return j.url.trim();
}
}
}
} catch (_) {
/* usar abs */
}
return abs;
}
function authBearerPayload() {
try {
const t = localStorage.getItem('authToken');
return t && t.length > 0 ? { authorizationBearer: t } : {};
} catch (_) {
return {};
}
}
function authBearerPayloadFor(url) {
try {
const u = new URL(url, window.location.origin);
if (u.origin !== window.location.origin) return {};
} catch (_) {
return {};
}
return authBearerPayload();
}
function elementToBounds(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.max(1, Math.round(r.width)),
height: Math.max(1, Math.round(r.height))
};
}
/**
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
*/
function getEmbedBounds() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
if (window.kiratvDesktop.embedUsesNativePanel) return null;
const watchPage = document.getElementById('page-watch');
if (watchPage && watchPage.classList.contains('active')) {
const videoEl = document.getElementById('watch-video');
if (videoEl) return elementToBounds(videoEl);
const section = document.querySelector('.watch-video-section');
if (section) return elementToBounds(section);
}
const livePage = document.getElementById('page-live');
if (livePage && livePage.classList.contains('active')) {
const ps = document.querySelector('.player-section');
if (ps) return elementToBounds(ps);
}
const ps = document.querySelector('.player-section');
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
const wv = document.getElementById('watch-video');
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
return null;
}
function embedUsesNativePanel() {
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
}
function setEmbeddedUiClass(active) {
const native = embedUsesNativePanel();
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
}
/**
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
*/
function shellChromeModeFromHash() {
const raw = location.hash || '#home';
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
if (h === 'live') return 'liveSplit';
if (h === 'watch') return 'vodSplit';
return 'fullWeb';
}
function syncShellChrome() {
const d = window.kiratvDesktop;
if (typeof d?.setShellChromeMode !== 'function') return;
const mode = shellChromeModeFromHash();
d.setShellChromeMode({ mode }).catch(() => {});
if (embedUsesNativePanel() && mode === 'fullWeb') {
d.stopExternalMpv?.().catch(() => {});
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
}
}
function initEmbedBoundsSync() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
if (embedUsesNativePanel()) return;
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
let t = null;
function flushBounds() {
clearTimeout(t);
t = setTimeout(() => {
const b = getEmbedBounds();
if (!b) return;
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
}, 72);
}
window.addEventListener('resize', flushBounds);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(flushBounds);
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
try {
ro.observe(el);
} catch (_) {
/* */
}
});
}
window.addEventListener('hashchange', flushBounds);
flushBounds();
}
function useEmbeddedPlayback() {
const d = window.kiratvDesktop;
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
}
async function playInMpv(absUrl, extraArgs) {
const d = window.kiratvDesktop;
if (!d?.playExternalMpv) {
showToast('Error: sin puente escritorio', 'error');
return { ok: false, error: 'Sin puente escritorio' };
}
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
if (useEmbeddedPlayback()) {
const native = embedUsesNativePanel();
let bounds = null;
if (!native) {
bounds = getEmbedBounds();
if (!bounds || bounds.width < 2 || bounds.height < 2) {
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
return { ok: false, error: 'bounds vídeo vacíos' };
}
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
try {
await d.setVideoHostBounds(bounds);
} catch (_) {
/* */
}
} else {
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
}
setEmbeddedUiClass(true);
const payload = {
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
};
if (!native && bounds) payload.bounds = bounds;
const result = await d.playEmbeddedMpv(payload);
if (!result?.ok) {
setEmbeddedUiClass(false);
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast('⚠ ' + msg, 'error', 6000);
}
return result;
}
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
const result = await d.playExternalMpv({
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
});
if (!result?.ok) {
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast(
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
'error',
6000
);
}
return result;
}
function whenReady(cb) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb, { once: true });
} else {
cb();
}
}
whenReady(() => {
if (window.kiratvDesktop?.isDesktopApp) {
document.body.classList.add('kiratv-desktop-app');
}
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
initEmbedBoundsSync();
}
window.addEventListener('hashchange', syncShellChrome);
setTimeout(syncShellChrome, 0);
});
window.kiratvDesktopClearEmbeddedChrome = function () {
setEmbeddedUiClass(false);
};
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
return playInMpv(absoluteUrl);
};
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
return playInMpv(absoluteUrl, extraArgs);
};
})();
+2 -2
View File
@@ -130,7 +130,7 @@ class SettingsPage {
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
@@ -575,7 +575,7 @@ class SettingsPage {
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
+26 -252
View File
@@ -79,11 +79,6 @@ class WatchPage {
this.isFavorite = false;
this.returnPage = null;
this.captionsMenuOpen = false;
this.sourceStreamUrl = null;
this.knownDurationSec = 0;
this.playbackBaseOffsetSec = 0;
this.isSessionMode = false;
this._heartbeatTimer = null;
// Overlay timer
this.overlayTimeout = null;
@@ -243,20 +238,6 @@ class WatchPage {
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.resumeTime = content.resumeTime || 0;
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';
// Stop any Live TV playback before starting movie/series
@@ -293,14 +271,6 @@ class WatchPage {
// Update title bar
this.titleEl.textContent = content.title || '';
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
await this.loadVideo(streamUrl);
@@ -338,7 +308,7 @@ class WatchPage {
const indicator = document.getElementById('now-playing-indicator');
const textEl = document.getElementById('now-playing-text');
if (indicator && textEl) {
textEl.textContent = title || 'Reproduciendo';
textEl.textContent = title || 'Now Playing';
indicator.classList.remove('hidden');
}
}
@@ -371,22 +341,10 @@ class WatchPage {
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
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;
} catch (err) {
console.error('[WatchPage] Session start failed:', err);
this.isSessionMode = true; // Still session-like for duration purposes
// Fallback to direct transcode if session fails
return `/api/transcode?url=${encodeURIComponent(url)}`;
}
}
@@ -395,21 +353,15 @@ class WatchPage {
* Stop and cleanup current transcode session
*/
async stopTranscodeSession() {
this._stopHeartbeat();
if (this.currentSessionId) {
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
const sid = this.currentSessionId;
this.currentSessionId = null;
try {
// sendBeacon works even when the page is being unloaded
if (navigator.sendBeacon) {
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
}
// Also DELETE (for normal navigation)
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
// Fire and forget cleanup
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
this.currentSessionId = null;
}
}
@@ -460,7 +412,6 @@ class WatchPage {
async loadVideo(url) {
// Store the URL for copy functionality
this.currentUrl = url;
this.sourceStreamUrl = url;
// Stop any existing playback
this.stop();
@@ -468,23 +419,6 @@ class WatchPage {
// Show loading spinner
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
let settings = {};
try {
@@ -528,7 +462,6 @@ class WatchPage {
audioCodec: info.audio,
audioChannels: info.audioChannels
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
@@ -548,30 +481,8 @@ class WatchPage {
// Compatible - fall through to normal playback
console.log('[WatchPage] Auto: Using normal playback (compatible)');
} catch (err) {
console.warn('[WatchPage] Probe failed:', err.message);
const vodLike =
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
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
// Continue with normal playback on probe failure
}
}
@@ -585,7 +496,6 @@ class WatchPage {
videoMode: 'encode',
seekOffset: this.resumeTime
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
@@ -609,7 +519,6 @@ class WatchPage {
videoCodec,
seekOffset: this.resumeTime
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
@@ -621,7 +530,6 @@ class WatchPage {
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.playbackBaseOffsetSec = 0;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
@@ -639,12 +547,10 @@ class WatchPage {
// Use HLS.js for HLS streams
if (looksLikeHls && Hls.isSupported()) {
this.updateTranscodeStatus('direct', 'Direct HLS');
this.playbackBaseOffsetSec = 0;
this.playHls(finalUrl);
} else {
// Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play');
this.playbackBaseOffsetSec = 0;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -715,13 +621,6 @@ class WatchPage {
this.stopHistoryTracking();
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
this.stopTranscodeSession();
this.updateTranscodeStatus('hidden');
@@ -738,12 +637,8 @@ class WatchPage {
}
if (this.video) {
this.video.pause();
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute).
if (!window.kiratvDesktop?.isDesktopApp) {
this.video.src = '';
this.video.load();
}
this.video.src = '';
this.video.load();
}
this.hideNowPlaying();
@@ -760,38 +655,15 @@ class WatchPage {
}
skip(seconds) {
if (!this.video) return;
const totalDuration = this.getTotalDuration();
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);
if (this.video) {
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
}
}
seek(percent) {
const totalDuration = this.getTotalDuration();
if (!this.video || !totalDuration) return;
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;
if (this.video && this.video.duration) {
this.video.currentTime = (percent / 100) * this.video.duration;
}
this.video.currentTime = Math.max(0, targetSec);
}
toggleMute() {
@@ -896,22 +768,18 @@ class WatchPage {
// === UI Updates ===
updateProgress() {
if (!this.video) return;
if (!this.video || !this.video.duration) return;
const totalDuration = this.getTotalDuration();
if (!totalDuration) return;
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);
const percent = (this.video.currentTime / this.video.duration) * 100;
this.progressSlider.value = percent;
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
// Show "Up Next" panel early for series (like streaming services do during credits)
// Only show if auto-play next episode is enabled
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
const duration = totalDuration;
const currentTime = currentAbs;
const duration = this.video.duration;
const currentTime = this.video.currentTime;
// Only proceed if we have reliable duration data
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
@@ -939,10 +807,8 @@ class WatchPage {
this.updateQualityBadge();
}
// Handle resumption (only for direct video, NOT session mode).
// In session mode FFmpeg already starts from the seekOffset, so
// video.currentTime=0 already represents the correct absolute position.
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
// Handle resumption
if (this.resumeTime > 0 && this.video) {
const duration = this.video.duration;
// Only resume if not near the end (95%)
if (!duration || this.resumeTime < duration * 0.95) {
@@ -950,14 +816,6 @@ class WatchPage {
this.video.currentTime = this.resumeTime;
}
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);
}
}
@@ -994,11 +852,11 @@ class WatchPage {
}
onError(e) {
// Only log actual fatal errors, not benign stream recovery events
const error = this.video?.error;
if (!error || !error.code) return;
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar)
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return;
console.error('[WatchPage] Video error:', error.code, error.message);
if (error && error.code) {
console.error('[WatchPage] Video error:', error.code, error.message);
}
}
updateVolumeUI() {
@@ -1018,90 +876,6 @@ class WatchPage {
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 ===
showLoading() {
@@ -1643,7 +1417,7 @@ class WatchPage {
try {
const data = {
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,
sourceId: this.content.sourceId,
containerExtension: this.containerExtension,
+2 -1
View File
@@ -67,7 +67,8 @@ function getDefaultSettings() {
forceVideoTranscode: false, // Force Video Transcode
forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8',
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts.
streamFormat: 'ts',
epgRefreshInterval: '24',
// User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
+66 -24
View File
@@ -1,6 +1,14 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
const { sources } = require('../db');
const { requireAuth } = require('../auth');
const {
getAccessibleSourceIds,
assertCanUseSourceById
} = require('../sourceAccess');
router.use(requireAuth);
// Helper to map API item types to DB types and tables
function mapItemType(apiType) {
@@ -21,27 +29,44 @@ router.get('/hidden', async (req, res) => {
const { sourceId } = req.query;
const db = getDb();
let hidden = [];
let allowedIds = null;
if (req.user.role !== 'admin') {
allowedIds = await getAccessibleSourceIds(req, sources);
if (allowedIds.length === 0) {
return res.json([]);
}
}
const resultFormat = (row, itemType) => ({
source_id: row.source_id,
item_type: itemType,
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
});
// Query Categories
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
let catParams = [];
let itemParams = [];
const params = [];
if (sourceId) {
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const sid = parseInt(sourceId, 10);
catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`;
const sid = parseInt(sourceId);
params.push(sid);
catParams.push(sid);
itemParams.push(sid);
} else if (allowedIds) {
const ph = allowedIds.map(() => '?').join(',');
catQuery += ` AND source_id IN (${ph})`;
itemQuery += ` AND source_id IN (${ph})`;
catParams = [...allowedIds];
itemParams = [...allowedIds];
}
const hiddenCats = db.prepare(catQuery).all(...params);
const hiddenItems = db.prepare(itemQuery).all(...params);
const hiddenCats = db.prepare(catQuery).all(...catParams);
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
const hidden = [];
hiddenCats.forEach(row => {
let apiType;
@@ -75,6 +100,7 @@ router.post('/hide', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -101,6 +127,7 @@ router.post('/show', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -127,6 +154,8 @@ router.get('/hidden/check', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.json({ hidden: false });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -148,13 +177,16 @@ router.post('/hide/bulk', async (req, res) => {
const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb();
// Prepare statements once
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (hide all children of a category)
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
@@ -162,12 +194,9 @@ router.post('/hide/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
// Hide the category
hideCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Hide individual item
hideItem.run(item.sourceId, mapping.type, item.itemId);
}
}
@@ -191,13 +220,16 @@ router.post('/show/bulk', async (req, res) => {
const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb();
// Prepare statements once
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (show all children of a category)
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
@@ -205,12 +237,9 @@ router.post('/show/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
// Show the category
showCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Show individual item
showItem.run(item.sourceId, mapping.type, item.itemId);
}
}
@@ -234,14 +263,15 @@ router.post('/show/all', async (req, res) => {
const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -264,14 +294,15 @@ router.post('/hide/all', async (req, res) => {
const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -296,6 +327,18 @@ router.get('/recent', async (req, res) => {
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
}
let accessibleFilter = '';
const params = [type];
if (req.user.role !== 'admin') {
const ids = await getAccessibleSourceIds(req, sources);
if (ids.length === 0) {
return res.json([]);
}
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
params.push(...ids);
}
params.push(parseInt(limit, 10));
const db = getDb();
const recentItems = db.prepare(`
SELECT * FROM playlist_items p
@@ -308,11 +351,11 @@ router.get('/recent', async (req, res) => {
AND c.type = p.type
AND c.is_hidden = 1
)
${accessibleFilter}
ORDER BY p.added_at DESC
LIMIT ?
`).all(type, parseInt(limit));
`).all(...params);
// Parse JSON data for each item
const formatted = recentItems.map(item => ({
...item,
data: JSON.parse(item.data)
@@ -326,4 +369,3 @@ router.get('/recent', async (req, res) => {
});
module.exports = router;
+5 -17
View File
@@ -1,7 +1,6 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const streamInput = require('../utils/streamInput');
/**
* Probe endpoint - detects stream codecs and container
@@ -31,27 +30,16 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
*/
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
return new Promise((resolve, reject) => {
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
const effectiveTimeout = sizing.heavy ? Math.max(timeout, 45000) : timeout;
const args = [
'-v', 'error',
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'
];
if (referer) {
args.push('-referer', referer);
}
args.push(
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-print_format', 'json',
'-show_streams',
'-show_format',
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
'-probesize', '5000000',
'-analyzeduration', '5000000',
url
);
];
const proc = spawn(ffprobePath, args);
let stdout = '';
@@ -60,7 +48,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error('Probe timeout'));
}, effectiveTimeout);
}, timeout);
proc.stdout.on('data', (data) => { stdout += data; });
proc.stderr.on('data', (data) => { stderr += data; });
+45 -18
View File
@@ -12,6 +12,35 @@ const https = require('https');
const { spawn } = require('child_process');
const ffmpegPath = require('ffmpeg-static');
const { Readable } = require('stream');
const auth = require('../auth');
const { canUserAccessSource } = require('../sourceAccess');
// Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
router.use((req, res, next) => {
const p = req.path || (req.url || '').split('?')[0];
if (p === '/stream' || p === '/image') return next();
return auth.requireAuth(req, res, next);
});
router.param('sourceId', async (req, res, next, id) => {
try {
const sid = parseInt(id, 10);
if (Number.isNaN(sid)) {
return res.status(400).json({ error: 'Invalid source id' });
}
const source = await sources.getById(sid);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
req.grantedSource = source;
next();
} catch (err) {
next(err);
}
});
// Default cache max age in hours
const DEFAULT_MAX_AGE_HOURS = 24;
@@ -83,8 +112,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
// Login / Authenticate
router.get('/xtream/:sourceId', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') return res.status(404).send('Source not found');
const source = req.grantedSource;
if (source.type !== 'xtream') return res.status(404).send('Source not found');
// Proxy auth check to upstream to ensure credentials are still valid
@@ -185,8 +214,7 @@ router.get('/xtream/:sourceId/series', async (req, res) => {
// Proxy series info request
router.get('/xtream/:sourceId/series_info', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const source = req.grantedSource;
const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required');
@@ -207,8 +235,7 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
// VOD Info
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const source = req.grantedSource;
const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required');
@@ -230,14 +257,14 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
// Returns the direct stream URL for a given stream ID
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const streamId = req.params.streamId;
const type = req.params.type || 'live';
const container = req.query.container || 'm3u8';
const container = req.query.container || 'ts';
// Construct the Xtream stream URL
// Format: http://server:port/live/username/password/streamId.container (for live)
@@ -393,8 +420,8 @@ router.delete('/cache/:sourceId', (req, res) => {
router.get('/xtream/:sourceId/:action', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
@@ -478,14 +505,14 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
*/
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const api = xtreamApi.createFromSource(source);
const { streamId, type = 'live' } = req.params;
const { container = 'm3u8' } = req.query;
const { container = 'ts' } = req.query;
const url = api.buildStreamUrl(streamId, type, container);
res.json({ url });
@@ -505,8 +532,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
router.get('/epg/:sourceId', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId);
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
const source = req.grantedSource;
if (source.type !== 'epg' && source.type !== 'xtream') {
return res.status(404).json({ error: 'Valid EPG source not found' });
}
@@ -567,8 +594,8 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
*/
router.post('/epg/:sourceId/channels', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'epg') {
const source = req.grantedSource;
if (source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' });
}
+137 -97
View File
@@ -5,29 +5,84 @@ const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser');
const { requireAuth, requireAdmin } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
// Get all sources
router.use(requireAuth);
function sanitizeSourceList(list) {
return list.map((s) => ({
...s,
password: s.password ? '••••••••' : null
}));
}
function parseOwnerId(body) {
if (body.ownerId === undefined || body.ownerId === '') return null;
const n = parseInt(body.ownerId, 10);
return Number.isNaN(n) ? null : n;
}
// Estimate by URL (must stay before /:id routes)
const M3U_LARGE_THRESHOLD = 50000;
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global sync — admin only
router.post('/sync-all', requireAdmin, async (req, res) => {
try {
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
// Get all sources (filtered by access)
router.get('/', async (req, res) => {
try {
const allSources = await sources.getAll();
// Don't expose passwords in list view
const sanitized = allSources.map(s => ({
...s,
password: s.password ? '••••••••' : null
}));
res.json(sanitized);
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
res.json(sanitizeSourceList(filtered));
} catch (err) {
console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Get sync status for all sources
// Get sync status (filtered for non-admins)
router.get('/status', async (req, res) => {
try {
const { getDb } = require('../db/sqlite');
const db = getDb();
const statuses = db.prepare('SELECT * FROM sync_status').all();
let statuses = db.prepare('SELECT * FROM sync_status').all();
if (req.user.role !== 'admin') {
const allowed = new Set(await getAccessibleSourceIds(req, sources));
statuses = statuses.filter((row) => allowed.has(row.source_id));
}
res.json(statuses);
} catch (err) {
console.error('Error getting sync status:', err);
@@ -39,13 +94,42 @@ router.get('/status', async (req, res) => {
router.get('/type/:type', async (req, res) => {
try {
const typeSources = await sources.getByType(req.params.type);
res.json(typeSources);
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s));
res.json(sanitizeSourceList(filtered));
} catch (err) {
console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Estimate by source ID (before bare GET /:id — same base path length)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(source.url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Get single source
router.get('/:id', async (req, res) => {
try {
@@ -53,6 +137,9 @@ router.get('/:id', async (req, res) => {
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
res.json(source);
} catch (err) {
console.error('Error getting source:', err);
@@ -73,8 +160,14 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid source type' });
}
const source = await sources.create({ type, name, url, username, password });
// Trigger Sync
let ownerId = null;
if (req.user.role === 'admin') {
ownerId = parseOwnerId(req.body);
} else {
ownerId = req.user.id;
}
const source = await sources.create({ type, name, url, username, password, ownerId });
syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source);
} catch (err) {
@@ -90,16 +183,23 @@ router.put('/:id', async (req, res) => {
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const { name, url, username, password } = req.body;
const updated = await sources.update(req.params.id, {
const updates = {
name: name || existing.name,
url: url || existing.url,
username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password
});
// Trigger Sync (if critical fields changed? safely just trigger it)
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
};
if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
updates.ownerId = parseOwnerId(req.body);
}
const updated = await sources.update(req.params.id, updates);
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
res.json(updated);
} catch (err) {
console.error('Error updating source:', err);
@@ -110,13 +210,15 @@ router.put('/:id', async (req, res) => {
// Delete source
router.delete('/:id', async (req, res) => {
try {
const sourceId = parseInt(req.params.id);
const sourceId = parseInt(req.params.id, 10);
const existing = await sources.getById(sourceId);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Cascade delete: Clean up SQLite data for this source
const db = getDb();
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
@@ -130,7 +232,6 @@ router.delete('/:id', async (req, res) => {
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
// Delete source config and related hidden items (favorites handled by db.js)
await sources.delete(sourceId);
res.json({ success: true });
@@ -143,14 +244,21 @@ router.delete('/:id', async (req, res) => {
// Toggle source enabled/disabled
router.post('/:id/toggle', async (req, res) => {
try {
const existing = await sources.getById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const updated = await sources.toggleEnabled(req.params.id);
if (!updated) {
return res.status(404).json({ error: 'Source not found' });
}
// If enabled, trigger sync
if (updated.enabled) {
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
}
res.json(updated);
@@ -163,11 +271,13 @@ router.post('/:id/toggle', async (req, res) => {
// Manual Sync
router.post('/:id/sync', async (req, res) => {
try {
const id = parseInt(req.params.id);
const id = parseInt(req.params.id, 10);
const source = await sources.getById(id);
if (!source) return res.status(404).json({ error: 'Source not found' });
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Trigger sync (async)
syncService.syncSource(id).catch(console.error);
res.json({ success: true, message: 'Sync started' });
@@ -184,6 +294,9 @@ router.post('/:id/test', async (req, res) => {
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
@@ -205,77 +318,4 @@ router.post('/:id/test', async (req, res) => {
}
});
// Estimate M3U playlist size (for large playlist warning)
const M3U_LARGE_THRESHOLD = 50000;
// Estimate by URL (for new sources before creation)
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Only M3U sources need estimation
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for URL...`);
const count = await m3uParser.countEntries(url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Estimate by source ID (for existing sources)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
// Only M3U sources need estimation
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
const count = await m3uParser.countEntries(source.url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global Sync - sync all enabled sources
router.post('/sync-all', async (req, res) => {
try {
// Trigger global sync (async - don't wait for completion)
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
module.exports = router;
+42
View File
@@ -0,0 +1,42 @@
/**
* Who may use a source for playback and API access.
* ownerId null/undefined: legacy shared — any authenticated user may use it.
* ownerId N: only that user id (and admins) may use it.
*/
function canUserAccessSource(user, source) {
if (!user || !source) return false;
if (user.role === 'admin') return true;
const oid = source.ownerId;
if (oid == null || oid === undefined) return true;
return Number(oid) === Number(user.id);
}
async function getAccessibleSourceIds(req, sourcesApi) {
const all = await sourcesApi.getAll();
return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id);
}
/** Returns false after sending res if missing, forbidden, or invalid. */
async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) {
const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10);
if (Number.isNaN(sid)) {
res.status(400).json({ error: 'Invalid source id' });
return false;
}
const source = await sourcesApi.getById(sid);
if (!source) {
res.status(404).json({ error: 'Source not found' });
return false;
}
if (!canUserAccessSource(req.user, source)) {
res.status(403).json({ error: 'No access to this source' });
return false;
}
return true;
}
module.exports = {
canUserAccessSource,
getAccessibleSourceIds,
assertCanUseSourceById
};