Compare commits

..
204 Commits
Author SHA1 Message Date
thePR0M3TH3AN 97c4ccff50 Merge pull request #112 from PR0M3TH3AN/unstable
Unstable
2025-09-25 20:41:49 -04:00
thePR0M3TH3AN a9ffddf679 Merge pull request #111 from PR0M3TH3AN/codex/add-webtorrent-health-indicator-for-videos
Add WebTorrent probe for grid health badges
2025-09-25 20:37:00 -04:00
thePR0M3TH3AN bc085ad3be Add WebTorrent probe for grid health badges 2025-09-25 20:31:25 -04:00
thePR0M3TH3AN a05e0c77fd Merge pull request #110 from PR0M3TH3AN/codex/add-zap-button-to-video-player-modal
Add zap button to video modal
2025-09-25 20:17:50 -04:00
thePR0M3TH3AN 27e45f56b5 Add zap button to video modal 2025-09-25 20:16:05 -04:00
thePR0M3TH3AN 069fcb1b7f Merge pull request #109 from PR0M3TH3AN/codex/enable-autoplay-and-volume-for-webtorrent-video
Allow WebTorrent playback to respect volume preference
2025-09-25 20:03:29 -04:00
thePR0M3TH3AN 54a6e05285 Allow WebTorrent playback to respect volume preference 2025-09-25 20:01:23 -04:00
thePR0M3TH3AN 9a44112c88 Merge pull request #108 from PR0M3TH3AN/codex/fix-alignment-of-cdn-and-torrent-indicators
Align CDN and torrent badges across video grids
2025-09-25 19:54:03 -04:00
thePR0M3TH3AN 836509e2f0 Fix torrent badge alignment and channel grid styling 2025-09-25 19:52:06 -04:00
thePR0M3TH3AN b51a8010d0 Merge pull request #106 from PR0M3TH3AN/codex/add-webtorrent-peer-indicator-5n39k5
Match WebTorrent badge styling to CDN indicator
2025-09-25 19:46:07 -04:00
thePR0M3TH3AN 83184bd32f Merge branch 'unstable' into codex/add-webtorrent-peer-indicator-5n39k5 2025-09-25 19:32:54 -04:00
thePR0M3TH3AN 40f87167b9 Align torrent badge styling with CDN indicator 2025-09-25 19:31:41 -04:00
thePR0M3TH3AN 84e3da0a87 Merge pull request #105 from PR0M3TH3AN/codex/add-webtorrent-peer-indicator
Align WebTorrent indicators with CDN badges
2025-09-25 19:21:51 -04:00
thePR0M3TH3AN 1e388757ff Align WebTorrent indicators with CDN badges 2025-09-25 19:21:18 -04:00
thePR0M3TH3AN dda977b91a Merge pull request #103 from PR0M3TH3AN/codex/add-streamable-badge-to-video-grid-qamd5t
Fix magnet-uri import version
2025-09-25 18:59:40 -04:00
thePR0M3TH3AN 61750bbbca Merge branch 'unstable' into codex/add-streamable-badge-to-video-grid-qamd5t 2025-09-25 18:59:20 -04:00
thePR0M3TH3AN b3b85b6323 Fix magnet-uri import version 2025-09-25 18:58:41 -04:00
thePR0M3TH3AN 6a0692c3e3 Merge pull request #102 from PR0M3TH3AN/codex/add-streamable-badge-to-video-grid
Add WebTorrent tracker health badges
2025-09-25 18:51:21 -04:00
thePR0M3TH3AN ddd8a393f4 Add tracker health badges for magnets 2025-09-25 18:50:12 -04:00
thePR0M3TH3AN 0ad6d53ebb Merge pull request #101 from PR0M3TH3AN/codex/add-bitcoin-zap-button-on-profile-page
Add zap button placeholder on channel profiles
2025-09-25 18:36:49 -04:00
thePR0M3TH3AN c1da7dbb39 Add zap button placeholder on channel profiles 2025-09-25 18:36:18 -04:00
thePR0M3TH3AN 4d0731fe6e Merge pull request #100 from PR0M3TH3AN/codex/fix-cors-error-for-live-video-checks
Handle opaque CDN probes via video fallback
2025-09-25 18:24:19 -04:00
thePR0M3TH3AN 614847c99b Handle opaque URL probes via video fallback 2025-09-25 18:23:44 -04:00
thePR0M3TH3AN df6878aef6 update 2025-09-25 18:16:16 -04:00
thePR0M3TH3AN 059112eab9 Merge pull request #99 from PR0M3TH3AN/codex/fix-video-grid-status-message
Ensure url health badges update after mounting
2025-09-25 18:12:15 -04:00
thePR0M3TH3AN 58f564d1b2 Simplify CDN health badge messages 2025-09-25 18:11:51 -04:00
thePR0M3TH3AN cb67c55753 Merge pull request #98 from PR0M3TH3AN/codex/fix-video-grid-status-message
Ensure url health badges update after mounting
2025-09-25 18:04:11 -04:00
thePR0M3TH3AN eb989bee89 Ensure url health badges update once cards mount 2025-09-25 18:03:48 -04:00
thePR0M3TH3AN ebcb402569 Merge pull request #97 from PR0M3TH3AN/codex/fix-status-message-for-live-video-detection
Improve hosted URL health badge detection
2025-09-25 17:56:46 -04:00
thePR0M3TH3AN 7e63771651 Improve hosted URL health badge detection 2025-09-25 17:52:54 -04:00
thePR0M3TH3AN 1238de1d71 Merge pull request #96 from PR0M3TH3AN/codex/fix-hosted-url-status-message
Show hosted playback success indicator in modal
2025-09-25 17:35:43 -04:00
thePR0M3TH3AN b3b0ca7979 Improve hosted URL status messaging 2025-09-25 17:35:11 -04:00
thePR0M3TH3AN 0153965fdf Merge pull request #95 from PR0M3TH3AN/codex/fix-cancel-button-functionality-in-video-edit-form
Abort edit flow when prompts are cancelled
2025-09-25 17:16:19 -04:00
thePR0M3TH3AN 65d7950932 Abort edit flow when prompts are cancelled 2025-09-25 17:14:23 -04:00
thePR0M3TH3AN 8ea130fb22 Merge pull request #94 from PR0M3TH3AN/codex/fix-video-ownership-error-on-edit
Fix ownership checks for video actions
2025-09-25 17:06:49 -04:00
thePR0M3TH3AN 93493815fb Fix ownership checks for video actions 2025-09-25 17:06:12 -04:00
thePR0M3TH3AN 3b6aef5dfd Merge pull request #93 from PR0M3TH3AN/codex/add-fade-in-effect-to-sidebar-elements
Document fade-in safeguards
2025-09-25 16:33:22 -04:00
thePR0M3TH3AN 61df14b2e4 Merge branch 'unstable' into codex/add-fade-in-effect-to-sidebar-elements 2025-09-25 16:33:11 -04:00
thePR0M3TH3AN 96c9707113 Document fade-in safeguards 2025-09-25 16:30:12 -04:00
thePR0M3TH3AN 457c80b1e7 Merge pull request #92 from PR0M3TH3AN/codex/add-fade-in-effect-to-sidebar-elements
Stop re-triggering interface fade-ins
2025-09-25 16:17:04 -04:00
thePR0M3TH3AN 35969b0fc6 Stop re-triggering interface fade-ins 2025-09-25 16:14:43 -04:00
thePR0M3TH3AN 9e3ca0b273 Merge pull request #91 from PR0M3TH3AN/codex/add-fade-in-effect-to-sidebar-elements
Add fade-in animations to static interface elements
2025-09-25 15:32:07 -04:00
thePR0M3TH3AN 6f0dca0d62 Add fade-in animations to static interface elements 2025-09-25 15:31:50 -04:00
thePR0M3TH3AN e90f903b01 Merge pull request #90 from PR0M3TH3AN/codex/cache-and-fade-in-thumbnail-images
Improve thumbnail caching and fade-in
2025-09-25 15:22:09 -04:00
thePR0M3TH3AN 0de68cbd6b Improve thumbnail caching and fade-in 2025-09-25 15:21:26 -04:00
thePR0M3TH3AN efa68321df Merge pull request #89 from PR0M3TH3AN/codex/add-caching-for-nostr-notes-and-profiles
Add local caching for events and profiles
2025-09-25 15:12:33 -04:00
thePR0M3TH3AN 90a3bf97fe Add local caching for events and profiles 2025-09-25 15:07:25 -04:00
thePR0M3TH3AN c5668bfc3b Merge pull request #88 from PR0M3TH3AN/codex/add-fade-in-effect-for-new-video-boxes
Add fade-in animation for new video cards
2025-09-25 14:54:08 -04:00
thePR0M3TH3AN ab1eaaa5a1 Add fade-in animation for new video cards 2025-09-25 14:42:57 -04:00
thePR0M3TH3AN 6fb9f91a34 Merge pull request #87 from PR0M3TH3AN/codex/fix-video-playback-issues
Fix modal playback race after cleanup
2025-09-25 14:36:50 -04:00
thePR0M3TH3AN 32f652e7a8 Fix modal playback race after cleanup 2025-09-25 14:36:09 -04:00
thePR0M3TH3AN 7c1675805e Merge pull request #86 from PR0M3TH3AN/codex/fix-image-loading-issues-and-placeholders
Ensure video thumbnails fall back to placeholder
2025-09-25 14:12:49 -04:00
thePR0M3TH3AN 67b8caa623 Ensure video thumbnails fall back to placeholder 2025-09-25 14:12:27 -04:00
thePR0M3TH3AN 1d0100619e Merge pull request #85 from PR0M3TH3AN/codex/add-mobile-friendly-toggle-menu-for-sidebar
Restore sidebar footer toggle
2025-09-25 14:10:12 -04:00
thePR0M3TH3AN b7ebed9843 Merge branch 'unstable' into codex/add-mobile-friendly-toggle-menu-for-sidebar 2025-09-25 14:09:45 -04:00
thePR0M3TH3AN 237724b8ac Restore sidebar footer toggle 2025-09-25 14:07:36 -04:00
thePR0M3TH3AN ac0777534e Merge pull request #84 from PR0M3TH3AN/codex/add-mobile-friendly-toggle-menu-for-sidebar
Add drop-up resources menu to sidebar
2025-09-25 13:58:34 -04:00
thePR0M3TH3AN 4cbcfcfbf4 Add drop-up resources menu to sidebar 2025-09-25 13:58:06 -04:00
thePR0M3TH3AN a593ea0f5b Merge pull request #83 from PR0M3TH3AN/codex/improve-modal-opening-speed
Speed up modal open animations
2025-09-25 13:26:17 -04:00
thePR0M3TH3AN 52553165d6 Speed up modal open animations 2025-09-25 13:24:05 -04:00
thePR0M3TH3AN ddd6e40696 Merge pull request #82 from PR0M3TH3AN/codex/fix-video-grid-loading-glitch
Improve thumbnail caching and skip tracking script in partials
2025-09-25 13:09:44 -04:00
thePR0M3TH3AN 2d48f89f45 Improve thumbnail loading stability and skip tracking in modals 2025-09-25 12:53:29 -04:00
thePR0M3TH3AN 6e5c73f731 Merge pull request #81 from PR0M3TH3AN/codex/fix-video-loading-glitch-and-tracker-script
Reduce thumbnail flicker and avoid modal tracking duplicates
2025-09-25 12:20:54 -04:00
thePR0M3TH3AN c56ffdfee6 Reduce video list thrash and skip modal tracking 2025-09-25 12:20:29 -04:00
thePR0M3TH3AN 0557014986 Merge pull request #80 from PR0M3TH3AN/codex/verify-tracking-script-implementation
Restore Umami automatic tracking
2025-09-25 11:49:14 -04:00
thePR0M3TH3AN 6c6641c961 Restore Umami automatic tracking 2025-09-25 11:48:31 -04:00
thePR0M3TH3AN 77936e7656 Merge pull request #79 from PR0M3TH3AN/codex/add-tracking-script-to-all-pages
Simplify tracking script integration
2025-09-25 11:18:36 -04:00
thePR0M3TH3AN 69ca1f1d58 Merge branch 'unstable' into codex/add-tracking-script-to-all-pages 2025-09-25 11:18:25 -04:00
thePR0M3TH3AN f9f9c8149c Simplify tracking script integration 2025-09-25 11:14:10 -04:00
thePR0M3TH3AN 9629ef01ed Merge pull request #78 from PR0M3TH3AN/codex/add-tracking-script-to-all-pages
Fix Umami tracking integration
2025-09-25 10:32:22 -04:00
thePR0M3TH3AN fa738b9cb7 Merge branch 'unstable' into codex/add-tracking-script-to-all-pages 2025-09-25 10:32:05 -04:00
thePR0M3TH3AN 7bdf93cb63 Fix Umami integration 2025-09-25 10:28:31 -04:00
thePR0M3TH3AN e58c16ab83 Merge pull request #77 from PR0M3TH3AN/codex/add-tracking-script-to-all-pages
Add centralized analytics tracking
2025-09-25 10:10:52 -04:00
thePR0M3TH3AN dbe4563bb7 Add centralized analytics tracking 2025-09-25 10:09:45 -04:00
thePR0M3TH3AN 73e5a6bc83 Merge pull request #76 from PR0M3TH3AN/codex/fix-cors-blocking-on-video-playback
Handle CORS-safe URL probes and resilient hosted playback
2025-09-25 09:19:15 -04:00
thePR0M3TH3AN 109f5e0746 Handle CORS-safe URL probes and resilient hosted playback 2025-09-25 09:18:43 -04:00
thePR0M3TH3AN c5b30921ae Merge pull request #75 from PR0M3TH3AN/codex/update-video-upload-and-playback-handling
Add hosted URL workflow with WebTorrent fallback
2025-09-25 08:35:47 -04:00
thePR0M3TH3AN c9ebc80ab4 Document v3 publishing schema 2025-09-25 08:35:19 -04:00
thePR0M3TH3AN c488f3fa16 Merge pull request #74 from PR0M3TH3AN/codex/update-agents.md-and-readme.md-rules
Document magnet safety rules for contributors
2025-09-25 07:41:10 -04:00
thePR0M3TH3AN 1a6371fee3 Document magnet safety rules for contributors 2025-09-25 07:40:42 -04:00
thePR0M3TH3AN 72f3e4f8fd Merge pull request #73 from PR0M3TH3AN/codex/implement-clarity-and-stability-improvements
Improve URL health caching and cleanup flows
2025-09-25 07:37:04 -04:00
thePR0M3TH3AN adf6bd56cd Improve URL health caching and cleanup flows 2025-09-25 07:35:08 -04:00
thePR0M3TH3AN d89b38006a Merge pull request #72 from PR0M3TH3AN/codex/implement-final-playback-strategy
Implement URL-first playback fallback
2025-09-25 07:10:37 -04:00
thePR0M3TH3AN 9bf283c2d4 Implement URL-first playback fallback 2025-09-25 07:07:27 -04:00
thePR0M3TH3AN 02de05e31a Merge pull request #71 from PR0M3TH3AN/codex/fix-data-attributes-for-url-and-magnet
Fix playback dataset handling for raw magnets
2025-09-25 07:00:39 -04:00
thePR0M3TH3AN 447aea3d4b Fix playback dataset handling for raw magnets 2025-09-25 06:58:30 -04:00
thePR0M3TH3AN b617648136 Merge pull request #70 from PR0M3TH3AN/codex/add-url-support-for-publishing-and-parsing
Normalize video version parsing
2025-09-25 06:41:25 -04:00
thePR0M3TH3AN 8a9e325c22 Normalize video version parsing 2025-09-25 06:41:02 -04:00
thePR0M3TH3AN d511757473 Merge pull request #69 from PR0M3TH3AN/codex/add-hosted-video-url-input-to-upload-modal
Add hosted URL and seed hint inputs to upload modal
2025-09-24 22:36:38 -04:00
thePR0M3TH3AN 0af08f60b0 Add hosted URL and seed hint inputs to upload modal 2025-09-24 22:36:25 -04:00
thePR0M3TH3AN ce8a2aab87 Merge pull request #68 from PR0M3TH3AN/codex/centralize-webtorrent-constraints-and-flags
feat: add runtime-toggable playback constants
2025-09-24 22:34:34 -04:00
thePR0M3TH3AN fbd6fa4121 feat: add runtime-toggable playback constants 2025-09-24 22:34:12 -04:00
thePR0M3TH3AN d6a34571a9 Merge pull request #67 from PR0M3TH3AN/codex/update-agents.md-and-readme.md-documentation
Update contributor docs for URL-first playback
2025-09-24 21:54:16 -04:00
thePR0M3TH3AN e85831bbf6 Update contributor docs for URL-first playback 2025-09-24 21:53:50 -04:00
thePR0M3TH3AN ff4fd71cbe Merge pull request #66 from PR0M3TH3AN/codex/implement-url-health-caching-and-probing
Add cached CDN health badges to video cards
2025-09-24 21:50:03 -04:00
thePR0M3TH3AN aaf9066666 Add cached URL health badges to video cards 2025-09-24 21:49:43 -04:00
thePR0M3TH3AN 72db5396b4 Merge pull request #65 from PR0M3TH3AN/codex/add-url-handling-and-playback-methods
Refine URL-first video playback flow
2025-09-24 21:43:15 -04:00
thePR0M3TH3AN 16d4578ead Refine URL-first video playback flow 2025-09-24 21:42:56 -04:00
thePR0M3TH3AN 14589b571c Merge pull request #64 from PR0M3TH3AN/codex/update-playback-data-handling-and-encoding
Fix playback data encoding for cards
2025-09-24 21:35:03 -04:00
thePR0M3TH3AN f482124411 Fix playback data encoding for cards 2025-09-24 21:34:01 -04:00
thePR0M3TH3AN f61291f75b Merge pull request #63 from PR0M3TH3AN/codex/update-video-publishing-and-parsing-logic-pdp50i
Update video converter and publishing schema
2025-09-24 21:29:55 -04:00
thePR0M3TH3AN 42bfde21d5 Update video converter and publishing schema 2025-09-24 21:28:36 -04:00
thePR0M3TH3AN 8e0a5e0034 Merge pull request #62 from PR0M3TH3AN/codex/fix-webtorrent-magnet-link-playback
Fix WebTorrent controller claim race
2025-09-24 21:16:22 -04:00
thePR0M3TH3AN b031e98c4a Fix service worker claiming for WebTorrent playback 2025-09-24 21:15:49 -04:00
thePR0M3TH3AN 5bf3112436 Merge pull request #61 from PR0M3TH3AN/codex/implement-normalizeandaugmentmagnet-helper
Add form magnet normalizer and tests
2025-09-24 20:56:40 -04:00
thePR0M3TH3AN 3b4bb7f0df Add form magnet normalizer and tests 2025-09-24 20:53:54 -04:00
thePR0M3TH3AN 5795a7e7d4 Merge pull request #60 from PR0M3TH3AN/codex/fix-video-not-loading-in-player-modal
Ensure WebTorrent modal waits for active service worker
2025-09-24 20:50:02 -04:00
thePR0M3TH3AN b66d6e1080 Fix WebTorrent modal playback when worker not controlling 2025-09-24 20:49:35 -04:00
thePR0M3TH3AN 8c730e772b Merge pull request #59 from PR0M3TH3AN/codex/fix-video-playback-in-player-modal
Fix WebTorrent playback regression in modal
2025-09-24 20:31:03 -04:00
thePR0M3TH3AN 8e42816ec5 Clear srcObject before replaying torrents 2025-09-24 20:30:41 -04:00
thePR0M3TH3AN e7d895f123 Merge pull request #58 from PR0M3TH3AN/codex/update-video-publishing-and-parsing-logic
Restore legacy magnet playback in converter
2025-09-24 20:18:51 -04:00
thePR0M3TH3AN 04127bc22a Merge branch 'unstable' into codex/update-video-publishing-and-parsing-logic 2025-09-24 20:18:37 -04:00
thePR0M3TH3AN 996647f23d Restore legacy magnet playback in converter 2025-09-24 20:11:40 -04:00
thePR0M3TH3AN b52076cc26 Refine legacy magnet scavenging after merge 2025-09-24 20:01:49 -04:00
thePR0M3TH3AN 940d9e66f7 Restore legacy magnet playback in converter 2025-09-24 19:57:29 -04:00
thePR0M3TH3AN 5211897412 Merge pull request #57 from PR0M3TH3AN/codex/update-video-publishing-and-parsing-logic
Handle legacy magnets in shared converter
2025-09-24 17:31:19 -04:00
thePR0M3TH3AN 4456719bc9 Handle legacy magnets in shared converter 2025-09-24 17:30:58 -04:00
thePR0M3TH3AN 96e1679618 update 2025-09-24 17:23:41 -04:00
thePR0M3TH3AN 82bd833594 Merge pull request #56 from PR0M3TH3AN/codex/implement-normalizeandaugmentmagnet-helper
Add magnet normalization helper and update upload flow
2025-09-24 17:11:55 -04:00
thePR0M3TH3AN 9b11fe720c Add magnet normalization helper and update upload flow 2025-09-24 17:08:36 -04:00
thePR0M3TH3AN 6ecd625fce Merge pull request #55 from PR0M3TH3AN/codex/locate-publishsubscriptionlist-with-nip04-encrypt
Explain encrypt-to-self subscription encryption
2025-09-24 16:32:04 -04:00
thePR0M3TH3AN 9342c967ba Explain encrypt-to-self subscriptions 2025-09-24 16:31:50 -04:00
thePR0M3TH3AN 9927b7e4ec Merge pull request #54 from PR0M3TH3AN/codex/add-comment-to-connecttorelays-implementation
Add documentation for relay readiness probe
2025-09-24 16:28:51 -04:00
thePR0M3TH3AN 2542e97398 Document relay readiness probe 2025-09-24 16:27:51 -04:00
thePR0M3TH3AN 5d0003c13d Merge pull request #53 from PR0M3TH3AN/codex/locate-probeurl-function-and-add-multi-line-comment
Document probeUrl HEAD and range fallback
2025-09-24 16:23:02 -04:00
thePR0M3TH3AN 713cc909c7 Document probeUrl HEAD and range fallback 2025-09-24 16:22:02 -04:00
thePR0M3TH3AN 02a1b5121d Merge pull request #52 from PR0M3TH3AN/codex/add-comments-for-autoplay-and-cross-origin-flags
Document autoplay flags in torrent handlers
2025-09-24 16:13:12 -04:00
thePR0M3TH3AN c94eb928e1 Document autoplay flags in torrent handlers 2025-09-24 16:12:51 -04:00
thePR0M3TH3AN c384fc515f Merge pull request #51 from PR0M3TH3AN/codex/expand-brave-specific-logic-comment
Document Brave service worker workaround
2025-09-24 16:10:38 -04:00
thePR0M3TH3AN c5f67bc31e Document Brave service worker workaround 2025-09-24 16:10:22 -04:00
thePR0M3TH3AN f0f1a7cf8f Merge pull request #50 from PR0M3TH3AN/codex/add-explanatory-comments-in-javascript-files
Document intentional post-parsing data-play assignments
2025-09-24 16:06:04 -04:00
thePR0M3TH3AN 8a10a7cfc8 Document intentional post-parsing data-play assignments 2025-09-24 16:05:45 -04:00
thePR0M3TH3AN 2ae9f01cb5 Merge pull request #49 from PR0M3TH3AN/codex/add-comment-explaining-data-play-attributes
Explain post-template data-play attribute assignment
2025-09-24 15:57:31 -04:00
thePR0M3TH3AN 48a1f24ac9 Explain post-template data-play attribute assignment 2025-09-24 15:55:27 -04:00
thePR0M3TH3AN b29ee486f0 Merge pull request #48 from PR0M3TH3AN/codex/add-comment-and-documentation-for-torrent-handler
Document Chrome CORS pruning workaround
2025-09-24 15:52:48 -04:00
thePR0M3TH3AN 9229f0fe80 Document Chrome CORS pruning workaround 2025-09-24 15:52:15 -04:00
thePR0M3TH3AN e0da106442 Merge pull request #47 from PR0M3TH3AN/codex/update-videoeventutils.js-with-comments
Document legacy magnet handling in URL parsing
2025-09-24 15:25:47 -04:00
thePR0M3TH3AN 532ce8e2d2 Document legacy magnet handling in URL parsing 2025-09-24 15:25:27 -04:00
thePR0M3TH3AN 86928b8e8c Merge pull request #46 from PR0M3TH3AN/codex/add-doc-comment-for-derivetorrentplaybackconfig
Add documentation for deriveTorrentPlaybackConfig magnet handling
2025-09-24 15:23:42 -04:00
thePR0M3TH3AN b52e767f86 Document torrent playback normalization details 2025-09-24 15:23:24 -04:00
thePR0M3TH3AN 1be2c124b7 Merge pull request #45 from PR0M3TH3AN/codex/add-doc-comment-for-normalizeandaugmentmagnet
Document magnet normalization behavior
2025-09-24 15:21:01 -04:00
thePR0M3TH3AN 0d56082c54 docs: document magnet normalization behavior 2025-09-24 15:20:40 -04:00
thePR0M3TH3AN d3f42b6eb3 Merge pull request #44 from PR0M3TH3AN/codex/update-upload-modal-with-new-inputs
Add hosted URL and torrent hints inputs to upload modal
2025-09-24 15:04:29 -04:00
thePR0M3TH3AN 2150ac0f0b Add hosted URL and torrent hints inputs to upload modal 2025-09-24 15:04:11 -04:00
thePR0M3TH3AN e4ae6fadef Merge pull request #43 from PR0M3TH3AN/codex/add-wss_trackers-and-update-magnetutils
fix: ensure modal poster clears when playback starts
2025-09-24 14:58:06 -04:00
thePR0M3TH3AN eb9b50b572 fix: ensure modal poster clears when playback starts 2025-09-24 14:57:11 -04:00
thePR0M3TH3AN 8188b9f71a Merge pull request #42 from PR0M3TH3AN/codex/add-wss_trackers-and-update-magnetutils
refactor: centralize tracker constants
2025-09-24 14:45:32 -04:00
thePR0M3TH3AN 52ecb76ac9 refactor: centralize tracker constants 2025-09-24 14:45:08 -04:00
thePR0M3TH3AN 80263bdba5 Merge pull request #41 from PR0M3TH3AN/codex/fallback-to-default-video-cover-on-error
Ensure modal loading poster clears during playback
2025-09-24 14:40:42 -04:00
thePR0M3TH3AN 57496969d7 Clear modal loading poster once playback starts 2025-09-24 14:40:08 -04:00
thePR0M3TH3AN d9afdabe6c Merge pull request #40 from PR0M3TH3AN/codex/fallback-to-default-video-cover-on-error
Ensure video thumbnails fallback to default image
2025-09-24 14:08:34 -04:00
thePR0M3TH3AN 621465c538 Ensure video thumbnails fallback to default image 2025-09-24 14:07:59 -04:00
thePR0M3TH3AN 1450e09697 Merge pull request #39 from PR0M3TH3AN/codex/fix-fallback-to-bitvid-placeholder-image
Improve thumbnail fallback handling
2025-09-24 13:59:28 -04:00
thePR0M3TH3AN dd47fbed93 Ensure thumbnails fall back to placeholder 2025-09-24 13:59:09 -04:00
thePR0M3TH3AN 548e0fd3c0 Merge pull request #38 from PR0M3TH3AN/codex/fix-fallback-for-video-cover-image
Add thumbnail fallback to lazy loader
2025-09-24 13:44:46 -04:00
thePR0M3TH3AN ed6b69b2e0 Add thumbnail fallback to lazy loader 2025-09-24 13:44:03 -04:00
thePR0M3TH3AN ee4ceb95b7 Merge pull request #37 from PR0M3TH3AN/codex/update-agents.md-and-fix-webtorrent-issue
Fix legacy WebTorrent magnet playback and refresh agent guidance
2025-09-24 13:07:37 -04:00
thePR0M3TH3AN 7742e3968c Fix magnet playback handling and update agent guidance 2025-09-24 13:07:14 -04:00
thePR0M3TH3AN 2442be4e77 Merge pull request #36 from PR0M3TH3AN/codex/fix-magnet-url-encoding-issues
Fix magnet handling and legacy magnet fallback
2025-09-24 12:39:31 -04:00
thePR0M3TH3AN 3593fdd0ef Fix magnet dataset handling and legacy fallback 2025-09-24 12:38:59 -04:00
thePR0M3TH3AN 873d1065e4 Merge pull request #35 from PR0M3TH3AN/codex/fix-magnet-decoding-and-legacy-note-handling
Fix encoded magnet playback and surface legacy notes
2025-09-24 11:35:57 -04:00
thePR0M3TH3AN 0d622bdb8d Fix magnet decoding and accept legacy notes 2025-09-24 11:26:46 -04:00
thePR0M3TH3AN fc0bae85af Merge pull request #34 from PR0M3TH3AN/codex/fix-invalid-torrent-identifier-error
Decode magnet URIs before torrent playback
2025-09-23 14:30:50 -04:00
thePR0M3TH3AN db43e3634b Decode magnet URIs before torrent playback 2025-09-23 14:21:07 -04:00
thePR0M3TH3AN c88bc404b6 Merge pull request #33 from PR0M3TH3AN/codex/fix-legacy-notes-and-magnet-handling
Handle legacy video notes and fix magnet encoding
2025-09-23 13:53:37 -04:00
thePR0M3TH3AN d271bd335c Clarify legacy handling comment 2025-09-23 13:52:33 -04:00
thePR0M3TH3AN fea5835a62 Merge pull request #32 from PR0M3TH3AN/codex/enhance-video-event-handling-and-testing
Handle legacy BitTorrent infohash notes
2025-09-23 13:29:42 -04:00
thePR0M3TH3AN 985acf6456 Support legacy infohash playback 2025-09-23 13:29:05 -04:00
thePR0M3TH3AN 3e7eab1c92 Merge pull request #31 from PR0M3TH3AN/codex/add-magnet-normalization-helper
Add magnet normalization helper and tests
2025-09-23 13:01:38 -04:00
thePR0M3TH3AN bfee3b7a2a Add magnet normalization helper and tests 2025-09-23 13:01:14 -04:00
thePR0M3TH3AN 88eaff3107 Merge pull request #30 from PR0M3TH3AN/codex/update-roadmap.md-with-new-visibility-filter-policy
Update roadmap visibility filtering policy
2025-09-23 10:53:27 -04:00
thePR0M3TH3AN 33f29380f5 Update roadmap visibility filtering policy 2025-09-23 10:52:39 -04:00
thePR0M3TH3AN ef2fad74f8 Merge pull request #29 from PR0M3TH3AN/codex/augment-magnet-handling-in-app.js
Ensure magnets include default trackers and HTTP fallback
2025-09-23 10:50:40 -04:00
thePR0M3TH3AN 167691d256 Ensure magnets include default trackers and hosted fallback 2025-09-23 10:50:23 -04:00
thePR0M3TH3AN e3ee938f8d Merge pull request #28 from PR0M3TH3AN/codex/add-compatibility-helper-for-video-events
Support legacy Nostr video payloads
2025-09-23 10:30:45 -04:00
thePR0M3TH3AN 0569755901 Support legacy Nostr video payloads 2025-09-23 10:30:21 -04:00
thePR0M3TH3AN a84f635539 Merge pull request #27 from PR0M3TH3AN/codex/tighten-magnet-uri-validation-and-handling
Handle unsupported WebTorrent magnets gracefully
2025-09-23 09:49:54 -04:00
thePR0M3TH3AN c6891ca57c Handle unsupported btih magnets in WebTorrent fallback 2025-09-23 09:49:35 -04:00
thePR0M3TH3AN 9f3f42181c Merge pull request #26 from PR0M3TH3AN/codex/update-rendersamegridstyle-for-videomap
Seed subscription videos map and reuse playback handler
2025-09-23 09:30:18 -04:00
thePR0M3TH3AN 0e0249ff91 Seed subscription videos map and reuse playback handler 2025-09-23 09:29:49 -04:00
thePR0M3TH3AN fe0b89697f Merge pull request #25 from PR0M3TH3AN/codex/refactor-loaduservideos-to-update-video-handling
Delegate channel profile video clicks to global handler
2025-09-23 09:22:15 -04:00
thePR0M3TH3AN 1e77d617ec Refine channel profile video click handling 2025-09-23 09:19:49 -04:00
thePR0M3TH3AN 56b478454c Merge pull request #24 from PR0M3TH3AN/codex/investigate-video-loading-errors
Guard torrent playback against invalid magnets
2025-09-22 15:33:16 -04:00
thePR0M3TH3AN 867e111a48 Guard torrent playback against invalid magnets 2025-09-22 15:32:24 -04:00
thePR0M3TH3AN 1256610429 Merge pull request #23 from PR0M3TH3AN/codex/refactor-video-list-click-handling
Ensure video list modal handler persists across view swaps
2025-09-22 15:17:18 -04:00
thePR0M3TH3AN 4107796639 Refactor video list modal handler attachment 2025-09-22 15:16:52 -04:00
thePR0M3TH3AN c84dc1e812 Merge pull request #22 from PR0M3TH3AN/codex/add-conditional-1063-event-building-after-publishvideo
Add NIP-94 mirror publishing with MIME inference
2025-09-22 15:09:03 -04:00
thePR0M3TH3AN 02ee898923 Add NIP-94 mirror publishing with MIME inference 2025-09-22 15:07:46 -04:00
thePR0M3TH3AN bd1c68d202 Merge pull request #21 from PR0M3TH3AN/codex/add-methods-and-refactor-video-playback
Implement URL-first playback with torrent fallback
2025-09-22 14:12:21 -04:00
thePR0M3TH3AN 886b02a5ff Implement URL-first playback with torrent fallback 2025-09-22 14:11:25 -04:00
thePR0M3TH3AN 0e5b871488 Merge pull request #20 from PR0M3TH3AN/codex/update-video-list-and-event-delegation-logic
Add URL-aware playback delegation with fallback handling
2025-09-22 13:25:58 -04:00
thePR0M3TH3AN 2ed1cc50db Add URL-aware playback delegation with fallback handling 2025-09-22 12:58:51 -04:00
thePR0M3TH3AN ad5f8958c5 Merge pull request #19 from PR0M3TH3AN/codex/update-video-object-validation-and-methods
Support URL-first playback with torrent fallback
2025-09-22 12:10:17 -04:00
thePR0M3TH3AN 9014371263 Support URL-only videos and update playback fallback 2025-09-22 12:05:15 -04:00
thePR0M3TH3AN 7350f43ca7 Merge pull request #18 from PR0M3TH3AN/codex/update-upload-form-handling-and-validation
Enhance upload form handling for URLs and magnets
2025-09-22 11:23:51 -04:00
thePR0M3TH3AN ba9bba517a Enhance upload form handling for URLs and magnets 2025-09-22 11:23:09 -04:00
thePR0M3TH3AN 811e1e30e1 Merge pull request #17 from PR0M3TH3AN/codex/add-hosted-video-url-input-to-modal
Add hosted URL and torrent helper inputs to upload modal
2025-09-22 11:20:12 -04:00
thePR0M3TH3AN 7812034f0d Update upload modal with URL and torrent helper fields 2025-09-22 10:59:39 -04:00
thePR0M3TH3AN b51b692f31 Added Agents.md 2025-09-22 09:57:19 -04:00
thePR0M3TH3AN 3ca66c7288 Merge pull request #16 from PR0M3TH3AN/codex/update-accesscontrol-constructor-logic
Improve access control list initialization
2025-09-22 09:55:15 -04:00
thePR0M3TH3AN fe0245e30c Improve access control list initialization 2025-09-22 09:54:19 -04:00
thePR0M3TH3AN 2a26ef58f6 Merge pull request #15 from PR0M3TH3AN/codex/add-disclaimer-modal-initialization-logic
Load disclaimer modal dynamically
2025-09-22 09:41:38 -04:00
thePR0M3TH3AN c4289161e1 Load disclaimer modal dynamically 2025-09-22 09:41:07 -04:00
Keep Creating Online 0373859ad3 added ~Bordut-Nodlex to whitelist 2025-02-23 11:40:07 -05:00
42 changed files with 7367 additions and 726 deletions

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# Bitvid — AI Agent Guide
This guide tells AI agents how to keep Bitvid aligned with the current product direction. Follow it whenever you touch code, content, or documentation inside this repository.
---
## 1. Release Channels: Main vs. Unstable
* **Main** is the production track. Anything merged here must preserve today’s UX and magnet safety guarantees. Rollbacks should be painless: keep commits atomic, avoid destructive migrations, and leave feature flags in a known-good default (`false` unless product explicitly flips them).
* **Unstable** is our experimentation lane. Gate risky behavior behind feature flags defined in `js/constants.js` and document the toggle/rollback plan in PR descriptions.
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately and annotate the AGENTS.md changelog with remediation tips so future agents do not repeat the mistake.
## 2. Mission: URL‑First Playback with WebTorrent Fallback
* **Goal:** Always deliver smooth playback while keeping hosting costs low.
* **Primary transport:** A hosted video URL (MP4/WebM/HLS/DASH) that the `<video>` element can stream directly.
* **Fallback transport:** A WebTorrent magnet that includes browser‑safe trackers plus optional HTTP hints.
* **Runtime behavior:** Call `playVideoWithFallback({ url, magnet })` (see `js/playbackUtils.js`). It probes the URL first; only when that fails should WebTorrent start.
* **Content contract:** Bitvid posts (Nostr kind `30078`) must include a `title` and at least one of `url` or `magnet`. Prefer to publish both along with optional `thumbnail`, `description`, and `mode` fields. When mirroring to NIP‑94 (`kind 1063`), copy the hosted URL and (optionally) the magnet so other clients can discover the same asset.
---
## 3. Magnet Handling — Do’s & Don’ts
* **Use the helpers every time:** Always run inbound values through `safeDecodeMagnet()` before playback or normalization, and call `normalizeAndAugmentMagnet()` (see `js/magnetUtils.js`) to append `ws=` / `xs=` hints without mutating hashes.
* **Keep magnets raw:** Persist the literal `magnet:?xt=urn:btih:...` string. Decode once, then feed that exact value to WebTorrent or storage. Never rely on re-encoding helpers that might alter casing.
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
* **HTTP hints:** Encourage HTTPS `ws=` web seeds (file roots only) and HTTPS `xs=` pointers to `.torrent` files so fallback peers warm quickly.
* **Tracker policy:** Browser code must ship WSS trackers only. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers.
---
## 4. Upload Modal Troubleshooting
1. **Required fields:** Validation must ensure a title exists and that either a hosted URL or a magnet (or both) is supplied. Optional `ws` / `xs` inputs should be appended only when a magnet is present.
2. **Magnet parsing errors:** When a user pastes encoded magnets, run `safeDecodeMagnet()` before normalizing. Show inline errors if decoding fails and confirm the raw `magnet:?xt=` string survives the round trip.
3. **URL-first promise:** After submission, confirm the resulting feed item carries `data-play-url` and `data-play-magnet` attributes so playback utilities can attempt the URL first.
4. **Mixed-content warnings:** If the page is served over HTTPS, reject `ws=` or `xs=` hints that begin with `http://` and show guidance to upgrade to HTTPS. Document the copy in README/UX strings so operators can align messaging.
5. **Telemetry hooks:** Keep existing analytics/logging untouched unless instructed. If you add new modal states, annotate them clearly in code comments.
6. **Modal regressions:** If validation or helper wiring breaks in Main, disable new feature flags and ship a revert PR immediately. Note the rollback steps in AGENTS.md for posterity.
---
## 5. Manual QA Checklist
Run this script before shipping notable UI changes, especially around upload/playback flows. Automate when possible, but manual verification is required for releases.
1. **Smoke test the modal**
* Launch the site, open the Upload modal, and confirm required-field validation (title + one of URL/magnet).
* Submit with URL only, magnet only, and both; ensure success states are clear.
2. **URL-first playback**
* Publish a post with both URL and magnet. Load the card and verify the player fetches the hosted URL first (check network tab or logs).
* Temporarily block the URL (e.g., devtools throttling or offline). Confirm playback seamlessly falls back to the magnet.
3. **Magnet hygiene**
* Paste an encoded magnet; ensure `safeDecodeMagnet()` returns the raw value and the final string still includes the original `xt` hash.
* Confirm `normalizeAndAugmentMagnet()` appends `ws=`/`xs=` hints without corrupting parameters.
4. **P2P hints**
* Verify magnets authored through the modal include HTTPS `ws=` and optional `xs=` values when provided.
* Ensure tracker lists come from `js/constants.js` and remain WSS-only.
5. **Cross-browser sanity**
* Spot-check playback in Chromium and Firefox (desktop) to ensure no console errors related to CORS, Range requests, or tracker connections.
Document the run in PR descriptions so QA can cross-reference results.
---
## 6. Additional Notes for Agents
* **Nostr interoperability:** Keep kind `30078` events as the source of truth and optionally mirror to kind `1063` so external clients see the hosted URL.
* **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI.
* **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above.
---
## 7. Content Schema v3 & Playback Rules
* **Event payloads:** New video notes serialize as version `3` with the JSON shape:
`{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`.
* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable.
* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied.
* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
**End of AGENTS.md**
+38 -5
View File
@@ -26,11 +26,25 @@
1. **Visit the Site**: Navigate to the live instance of **bitvid** (e.g., `[https://bitvid.network](https://bitvid.network)`).
2. **Login with Nostr**:
- Use a compatible Nostr browser extension or manually input your public key.
3. **Upload Videos**:
- Provide a title, magnet link, and optional thumbnail or description.
- Toggle "Private" for encrypted listings.
4. **Stream Videos**:
- Play videos directly in the browser using WebTorrent technology.
### Post a video
The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly recommended so the player can start instantly, and you may also add a magnet for resilience.
- **Title**: Required. Matches the in-app validation copy.
- **Hosted URL (recommended)**: Supply an HTTPS MP4/WebM/HLS/DASH asset. This is the primary playback path.
- **Magnet (optional but encouraged)**: Paste the literal `magnet:?xt=urn:btih:...` string. The form decodes it with `safeDecodeMagnet()` to prevent hash corruption. Never wrap magnets in `new URL()`—store them raw or decode then pass directly to the helpers.
- **Web seeds (`ws=`)**: HTTPS only. Point to a file root (e.g., `https://cdn.example.com/video/`). Mixed-content (`http://`) hints are rejected just like the modal message explains.
- **Additional sources (`xs=`)**: Recommend adding an HTTPS `.torrent` link so WebTorrent peers can bootstrap faster.
- **Trackers**: Bitvid’s browser client only connects to WSS trackers shipped in `js/constants.js`. Do not add UDP or plaintext HTTP trackers to published magnets.
- **Private toggle**: Encrypts the listing for invited viewers.
### How playback works
1. **URL-first**: `playVideoWithFallback({ url, magnet })` attempts the hosted URL immediately. Healthy URLs deliver the full experience without touching P2P resources.
2. **WebTorrent fallback**: If the URL probe fails or returns an error status, Bitvid falls back to WebTorrent using the raw magnet. The helpers append HTTPS `ws=`/`xs=` hints so peers seed quickly.
3. **Safety checks**: Magnets are decoded with `safeDecodeMagnet()` and normalized via `normalizeAndAugmentMagnet()` before reaching WebTorrent. Trackers remain WSS-only to satisfy browser constraints.
4. **Operator playbook**: If a deployment causes playback regressions, flip the relevant feature flags back to their default values in `js/constants.js` and redeploy. Capture the rollback steps in AGENTS.md and the PR description so the Main channel stays stable.
---
@@ -67,6 +81,10 @@ To run **bitvid** locally:
- **`config.js`**:
- Toggle `isDevMode` for development (`true`) or production (`false`).
- **`js/constants.js`**:
- Source for browser-safe tracker lists and feature flags that govern WebTorrent behavior.
- **Magnet helpers**:
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
### Adding Features
@@ -101,6 +119,21 @@ To run **bitvid** locally:
- Follow the [MIT License](https://opensource.org/licenses/MIT).
- Use clear, concise commit messages.
- Respect the existing coding style and architecture.
- Run the manual QA script (see below) and note results in PR descriptions for changes that affect upload or playback.
---
## Testing
Use the manual QA checklist before releases or when altering upload/playback flows:
1. Open the Upload modal, confirm validation (title plus URL or magnet), and test submissions for URL-only, magnet-only, and combined entries.
2. Publish a post with both URL and magnet, verify the player streams the hosted URL, then simulate a URL failure and confirm WebTorrent playback.
3. Paste encoded magnets to ensure `safeDecodeMagnet()` returns the raw string and `normalizeAndAugmentMagnet()` adds `ws=` / `xs=` hints without corruption.
4. Confirm magnets include HTTPS `ws=` / optional `xs=` hints and use the WSS tracker list from `js/constants.js`.
5. Spot-check Chromium and Firefox for console warnings (CORS, Range requests, tracker connectivity).
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
---
+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>

After

Width:  |  Height:  |  Size: 263 B

+1
View File
@@ -34,6 +34,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<script src="./js/tracking.js" defer></script>
<script type="module" crossorigin>
var pv = Object.defineProperty;
var gv = (e, t, n) =>
+186 -180
View File
@@ -1,6 +1,11 @@
<!--
Sidebar fades in once when hydrated on the client. js/index.js removes the
helper classes after the initial animation to avoid flashes each time we
re-render navigation links.
-->
<aside
id="sidebar"
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto"
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto fade-in fade-in-delay-100"
>
<div class="flex flex-col h-full">
<!-- Top Navigation Links -->
@@ -45,178 +50,182 @@
</div>
</div>
<!-- Mobile Dropdown Button for Footer Links -->
<!-- Added p-4 around the button for spacing, and styling consistent with top links -->
<div class="md:hidden p-4">
<button
id="footerDropdownButton"
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
>
<!-- Arrow on the left side -->
<svg
id="footerDropdownIcon"
class="w-4 h-4 mr-2 transform transition-transform duration-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
<span id="footerDropdownText">More</span>
</button>
</div>
<!-- Footer Links Container -->
<!-- On mobile: hidden by default; on desktop: visible and pushed to the bottom -->
<div id="footerLinksContainer" class="hidden md:block md:mt-auto">
<hr class="border-gray-700" />
<!-- Footer Links Toggle -->
<div class="md:mt-auto">
<div class="p-4">
<nav class="space-y-2 text-sm opacity-70">
<a
href="#view=about"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
<button
id="footerDropdownButton"
type="button"
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
aria-expanded="false"
aria-controls="footerLinksContainer"
>
<!-- Arrow on the left side -->
<svg
id="footerDropdownIcon"
class="w-4 h-4 mr-2 transform transition-transform duration-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<img
src="assets/svg/about-icon.svg"
alt="About"
class="w-5 h-5 mr-3"
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
<span class="sidebar-text">About</span>
</a>
<a
href="#view=community-guidelines"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/guidelines-icon.svg"
alt="Guidelines"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Guidelines</span>
</a>
<a
href="#view=getting-started"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/getting-started-icon.svg"
alt="Getting Started"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Getting Started</span>
</a>
<a
href="#view=roadmap"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/roadmap-icon.svg"
alt="Roadmap"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Roadmap</span>
</a>
<a
href="#view=blog"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/blog-icon.svg"
alt="Blog"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Blog</span>
</a>
<a
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/nostr-icon.svg"
alt="Nostr"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Nostr</span>
</a>
<a
href="https://github.com/PR0M3TH3AN/bitvid"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/github-icon.svg"
alt="GitHub"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">GitHub</span>
</a>
<a
href="torrent/beacon.html"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/beacon-icon.svg"
alt="βeacon"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">βeacon</span>
</a>
<a
href="https://beta.bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/beta-icon.svg"
alt="Beta"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Beta</span>
</a>
<a
href="#view=links"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/links-icon.svg"
alt="Links"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Links</span>
</a>
<a
href="#view=ipns"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/ipns-icon.svg"
alt="IPNS"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">IPNS</span>
</a>
<a
href="https://bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
<span class="sidebar-text">DNS</span>
</a>
</nav>
</svg>
<span id="footerDropdownText">More</span>
</button>
</div>
<!-- Footer Links Container -->
<div id="footerLinksContainer" class="hidden">
<hr class="border-gray-700" />
<div class="p-4">
<nav class="space-y-2 text-sm opacity-70">
<a
href="#view=about"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/about-icon.svg"
alt="About"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">About</span>
</a>
<a
href="#view=community-guidelines"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/guidelines-icon.svg"
alt="Guidelines"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Guidelines</span>
</a>
<a
href="#view=getting-started"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/getting-started-icon.svg"
alt="Getting Started"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Getting Started</span>
</a>
<a
href="#view=roadmap"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/roadmap-icon.svg"
alt="Roadmap"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Roadmap</span>
</a>
<a
href="#view=blog"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/blog-icon.svg"
alt="Blog"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Blog</span>
</a>
<a
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/nostr-icon.svg"
alt="Nostr"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Nostr</span>
</a>
<a
href="https://github.com/PR0M3TH3AN/bitvid"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/github-icon.svg"
alt="GitHub"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">GitHub</span>
</a>
<a
href="torrent/beacon.html"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/beacon-icon.svg"
alt="βeacon"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">βeacon</span>
</a>
<a
href="https://beta.bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/beta-icon.svg"
alt="Beta"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Beta</span>
</a>
<a
href="#view=links"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/links-icon.svg"
alt="Links"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">Links</span>
</a>
<a
href="#view=ipns"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img
src="assets/svg/ipns-icon.svg"
alt="IPNS"
class="w-5 h-5 mr-3"
/>
<span class="sidebar-text">IPNS</span>
</a>
<a
href="https://bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
<span class="sidebar-text">DNS</span>
</a>
</nav>
</div>
</div>
</div>
</div>
@@ -228,18 +237,15 @@
if (btn && footerLinks) {
btn.addEventListener("click", function () {
footerLinks.classList.toggle("hidden");
var isHidden = footerLinks.classList.contains("hidden");
btn.setAttribute("aria-expanded", String(!isHidden));
var icon = document.getElementById("footerDropdownIcon");
var textSpan = document.getElementById("footerDropdownText");
if (footerLinks.classList.contains("hidden")) {
textSpan.textContent = "More";
if (icon) {
icon.classList.remove("rotate-180");
}
} else {
textSpan.textContent = "Less";
if (icon) {
icon.classList.add("rotate-180");
}
if (textSpan) {
textSpan.textContent = isHidden ? "More" : "Less";
}
if (icon) {
icon.classList.toggle("rotate-180", !isHidden);
}
});
}
+46 -4
View File
@@ -24,7 +24,7 @@
>
<!-- Top Bar (similar to video-modal) -->
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300 p-4 flex items-center justify-between"
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
>
<h2 class="text-xl font-bold text-white">Share a Video</h2>
<button
@@ -52,6 +52,9 @@
<!-- Form Content -->
<div class="p-6 space-y-4">
<form id="uploadForm" class="space-y-4">
<p class="text-sm text-gray-400">
Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.
</p>
<div>
<label
for="uploadTitle"
@@ -66,18 +69,57 @@
/>
</div>
<!-- Hosted video URL -->
<div class="form-group">
<label
for="uploadUrl"
class="block text-sm font-medium text-gray-200"
>Hosted video URL (https)</label
>
<input
id="uploadUrl"
type="url"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/video.mp4"
/>
<small class="mt-1 block text-xs text-gray-400"
>Optional but preferred. We will play this first and fall back to
P2P.</small
>
</div>
<!-- Optional: seed bootstraps for magnet -->
<div class="grid grid-cols-1 gap-3 mb-2 text-xs">
<input
id="uploadWs"
type="url"
class="w-full p-2 rounded"
placeholder="Web seed (ws=) e.g., https://cdn.example.com/video.mp4"
>
<input
id="uploadXs"
type="url"
class="w-full p-2 rounded"
placeholder=".torrent URL (xs=) e.g., https://cdn.example.com/video.torrent"
>
</div>
<div>
<label
for="uploadMagnet"
class="block text-sm font-medium text-gray-200"
>Magnet Link</label
>Magnet link (optional if URL provided)</label
>
<input
type="text"
id="uploadMagnet"
required
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="magnet:?xt=urn:btih:..."
class="mt-1 block w-full rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">
Provide a hosted URL for instant playback. Add a magnet for P2P
cost control.
</p>
</div>
<div>
+14 -1
View File
@@ -9,7 +9,7 @@
<!-- Navigation bar - sliding at top -->
<div
id="modalNav"
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300"
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150"
>
<div class="flex items-center px-6 py-4">
<button
@@ -51,6 +51,19 @@
class="icon-image"
/>
</button>
<!-- Zap Button (circular) -->
<button
id="modalZapBtn"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<!-- Share Button (circular) -->
<button id="shareBtn" class="icon-button">
<img
+3 -3
View File
@@ -46,9 +46,9 @@
- Add zaps to videos, profiles, and comments.
- Create a VRR (View, Rating, and Retention) Penalty Scoring system. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/07_bitvid_Enhanced_View_Rating_%26_Retention_Penalty_Scoring.md)
- Implement visibility filtering for videos:
- Show only videos whose magnet links have at least **one active peer online**.
- Integrate the filtering mechanism into the video list rendering process.
- Update the video list dynamically based on real-time peer availability. (this may not be possible)
- Run lightweight URL-health probes so cards stay visible when the hosted playback link responds.
- When a URL is unreachable, fall back to WebTorrent and surface items only if their magnets can stream via peers or web seeds.
- Align the filtering logic with the player’s URL-first, WebTorrent-fallback behavior during list rendering.
- Dynamic Home Page and Video Tracking Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_%26_Video_Tracking_Specification.md)
- Add multi-language support for content and filtration. (v4?)
- Create a settings menu for local account preferences, including relay, adult content, theme, and language.
+151 -1
View File
@@ -8,6 +8,7 @@
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--modal-motion-duration: 160ms;
}
/* Core Styles */
@@ -73,6 +74,92 @@ header img {
box-shadow: var(--shadow-md);
}
.video-card--enter {
opacity: 0;
animation: video-card-fade-in 220ms ease-out forwards;
}
@keyframes video-card-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/*
Thumbnails start transparent and fade in the first time they finish loading.
`data-thumbnail-loaded` is cleared by js/index.js once the animation completes
so re-rendering the same element later does not replay the transition and cause
a distracting flash.
*/
[data-video-thumbnail][data-thumbnail-loaded="true"] {
animation: video-thumbnail-fade-in 220ms ease-out forwards;
}
@keyframes video-thumbnail-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/*
Generic fade-in utility for chrome elements (sidebar, header controls, etc.).
js/index.js listens for the `interface-fade-in` animation finishing and removes
the temporary classes so that once an element has gracefully appeared it stays
opaque even if the DOM is re-used or re-parented later.
*/
.fade-in {
opacity: 0;
animation: interface-fade-in 280ms ease-out forwards;
animation-delay: var(--fade-in-delay, 0ms);
}
.fade-in-delay-100 {
--fade-in-delay: 100ms;
}
.fade-in-delay-200 {
--fade-in-delay: 200ms;
}
.fade-in-delay-300 {
--fade-in-delay: 300ms;
}
.fade-in-delay-400 {
--fade-in-delay: 400ms;
}
@keyframes interface-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.video-card--enter,
.fade-in {
opacity: 1;
animation: none;
}
[data-video-thumbnail][data-thumbnail-loaded="true"] {
animation: none;
opacity: 1;
}
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
@@ -137,7 +224,7 @@ header img {
display: flex;
flex-direction: column;
background-color: #0f172a;
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn var(--modal-motion-duration) ease-out;
}
@keyframes fadeIn {
@@ -151,6 +238,12 @@ header img {
}
}
@media (prefers-reduced-motion: reduce) {
.modal-content {
animation-duration: 1ms;
}
}
/* Video Container */
.video-container {
width: 100%;
@@ -541,3 +634,60 @@ footer a:hover {
#sidebar hr {
border-color: rgba(255, 255, 255, 0.1);
}
/* Sidebar drop-up menu styling */
.sidebar-dropup {
display: flex;
flex-direction: column-reverse;
gap: 0.75rem;
}
.sidebar-dropup-panel {
background-color: #1f2937; /* gray-800 */
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 0.75rem;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.45);
max-height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateY(16px);
transition: max-height 0.35s ease, opacity 0.25s ease, transform 0.35s ease;
}
.sidebar-dropup-panel__inner {
padding: 1.25rem;
}
.sidebar-dropup-panel--expanded {
max-height: 640px;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.sidebar-dropup-trigger {
align-items: center;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(14, 116, 144, 0.35));
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 9999px;
color: #f8fafc;
display: flex;
justify-content: space-between;
padding: 0.75rem 1.25rem;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
width: 100%;
}
.sidebar-dropup-trigger:hover,
.sidebar-dropup-trigger:focus {
border-color: rgba(148, 163, 184, 0.6);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
outline: none;
transform: translateY(-1px);
}
.sidebar-dropup-trigger__content {
align-items: center;
display: flex;
}
+21
View File
@@ -0,0 +1,21 @@
# Bitvid Manual QA Script
Run this checklist before publishing releases or merging changes that touch upload or playback flows. Copy/paste the steps into QA tickets when requesting validation.
1. **Upload Modal Smoke Test**
- Open the Upload modal, verify title is required, and ensure either a hosted URL or a magnet (or both) must be supplied.
- Submit three variants: URL-only, magnet-only, and both URL + magnet. Confirm success messaging or event publication in each case.
2. **URL-First Playback**
- For a post with both URL and magnet, play the video and confirm the hosted URL loads first (inspect network requests or logs).
- Simulate a dead URL (offline mode or request blocker) and confirm playback falls back to WebTorrent without breaking the UI.
3. **Magnet Safety**
- Paste an encoded magnet and confirm `safeDecodeMagnet()` outputs the raw string before playback.
- Ensure `normalizeAndAugmentMagnet()` adds provided `ws=` / `xs=` hints while keeping the original `xt` hash intact.
4. **P2P Hints & Trackers**
- Confirm magnets generated through the modal include HTTPS `ws=` seeds and optional HTTPS `xs=` torrent URLs when supplied.
- Check that tracker arrays originate from `js/constants.js` and only include WSS endpoints.
5. **Cross-Browser Verification**
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+24 -5
View File
@@ -39,6 +39,7 @@
<meta name="theme-color" content="#0f172a" />
<!-- Styles -->
<script src="js/tracking.js" defer></script>
<link href="css/tailwind.min.css" rel="stylesheet" />
<link href="css/style.css" rel="stylesheet" />
<link href="css/markdown.css" rel="stylesheet" />
@@ -58,14 +59,25 @@
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
On mobile, we also toggle .sidebar-open to shift the entire content.
-->
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col">
<!--
`fade-in` is scrubbed by js/index.js after the first animation so that
re-rendering views does not cause the shell to flicker. Only apply it to
elements that should animate exactly once on initial paint.
-->
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col fade-in">
<!-- Header -->
<header class="mb-8 flex items-center w-full">
<!-- Mobile hamburger button (hidden on md+) -->
<!-- New button -->
<!--
Mobile menu button: uses `fade-in` to match the rest of the chrome.
The JS animation cleanup removes the helper classes once the entrance
finishes to prevent flashes on future sidebar toggles.
-->
<button
id="mobileMenuBtn"
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60]"
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60] fade-in"
>
<img
src="assets/svg/mobile-sidebar-menu-icon.svg"
@@ -74,14 +86,18 @@
/>
</button>
<!-- Logo -->
<!-- Logo fades in once on initial load. -->
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="BitVid Logo"
class="h-16"
class="h-16 fade-in fade-in-delay-100"
/>
<!-- Buttons on the far right -->
<div class="ml-auto flex items-center space-x-4">
<!-- Header action buttons share the fade-in treatment for consistency. -->
<div class="ml-auto flex items-center space-x-4 fade-in fade-in-delay-200">
<!-- Login Button -->
<button
id="loginButton"
@@ -140,7 +156,10 @@
<div id="modalContainer"></div>
<!-- Tagline / Slogan -->
<div class="text-center mb-8">
<!-- Tagline fade matches the cards. -->
<div class="text-center mb-8 fade-in fade-in-delay-300">
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
seed. zap. subscribe.
</h2>
+92 -18
View File
@@ -7,44 +7,118 @@ class AccessControl {
constructor() {
// Debug logging for initialization
console.log("DEBUG: AccessControl constructor called");
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
// Initialize empty sets
this.whitelist = new Set(initialWhitelist);
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
if (storedWhitelist !== null) {
this.whitelist = new Set(storedWhitelist);
} else {
this.whitelist = new Set(initialWhitelist);
this.saveWhitelist();
if (whitelistStatus && whitelistStatus !== "missing") {
console.warn(
`Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
);
}
}
if (storedBlacklist !== null) {
this.blacklist = new Set(storedBlacklist);
} else {
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
this.saveBlacklist();
if (blacklistStatus && blacklistStatus !== "missing") {
console.warn(
`Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
);
}
}
// Debug the sets
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
// Save to localStorage
this.saveWhitelist();
this.saveBlacklist();
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
}
// Rest of the class remains the same...
loadWhitelist() {
try {
const stored = localStorage.getItem("bitvid_whitelist");
return stored ? JSON.parse(stored) : [];
if (!stored) {
if (isDevMode) console.log("No stored whitelist found in localStorage.");
return { data: null, status: "missing" };
}
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "whitelist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading whitelist:", error);
return [];
console.error("Error loading whitelist, using defaults:", error);
return { data: null, status: "error" };
}
}
loadBlacklist() {
try {
const stored = localStorage.getItem("bitvid_blacklist");
return stored ? JSON.parse(stored) : [];
if (!stored) {
if (isDevMode) console.log("No stored blacklist found in localStorage.");
return { data: null, status: "missing" };
}
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "blacklist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading blacklist:", error);
return [];
console.error("Error loading blacklist, using defaults:", error);
return { data: null, status: "error" };
}
}
sanitizeList(list, listName) {
if (!Array.isArray(list)) {
console.warn(
`Stored ${listName} is not an array. Received:`,
list
);
return null;
}
const sanitized = [];
let invalidEntries = 0;
list.forEach((value) => {
if (typeof value !== "string") {
invalidEntries += 1;
return;
}
const trimmed = value.trim();
if (trimmed) {
sanitized.push(trimmed);
} else {
invalidEntries += 1;
}
});
if (invalidEntries > 0) {
console.warn(
`Stored ${listName} contained ${invalidEntries} invalid entr${
invalidEntries === 1 ? "y" : "ies"
}. Sanitized list:`,
sanitized
);
}
return sanitized;
}
saveWhitelist() {
try {
localStorage.setItem(
+194
View File
@@ -0,0 +1,194 @@
// js/analytics.js
import { ANALYTICS_CONFIG } from "./analyticsConfig.js";
const SCRIPT_ATTR = "data-bitvid-analytics";
const SCRIPT_IDENTIFIER = "umami";
const pendingCalls = [];
let flushTimerId = null;
let scriptLoadedOnce = false;
function isBrowserEnvironment() {
return typeof window !== "undefined" && typeof document !== "undefined";
}
function flushPendingCalls() {
if (!isBrowserEnvironment()) {
return;
}
const umami = window.umami;
if (!umami) {
return;
}
while (pendingCalls.length > 0) {
const { method, args } = pendingCalls.shift();
const fn = typeof umami[method] === "function" ? umami[method] : null;
if (!fn) {
continue;
}
try {
fn.apply(umami, args);
} catch (err) {
console.warn("[analytics] Failed to call", method, err);
}
}
}
function scheduleFlush() {
if (!isBrowserEnvironment()) {
return;
}
if (flushTimerId !== null) {
return;
}
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
flushTimerId = window.setInterval(() => {
if (window.umami && typeof window.umami.track === "function") {
window.clearInterval(flushTimerId);
flushTimerId = null;
flushPendingCalls();
}
}, 500);
}
export function ensureAnalyticsLoaded(doc = typeof document !== "undefined" ? document : null) {
if (!doc) {
return null;
}
let script = doc.querySelector(`script[${SCRIPT_ATTR}]`);
if (script) {
if (!scriptLoadedOnce) {
// If a server rendered script already exists, make sure we flush asap.
scriptLoadedOnce = true;
flushPendingCalls();
}
return script;
}
script = doc.createElement("script");
script.defer = true;
script.src = ANALYTICS_CONFIG.scriptSrc;
script.setAttribute(SCRIPT_ATTR, SCRIPT_IDENTIFIER);
script.dataset.websiteId = ANALYTICS_CONFIG.websiteId;
// Allow Umami to handle automatic pageview tracking by default.
script.addEventListener("load", () => {
scriptLoadedOnce = true;
flushPendingCalls();
});
doc.head.appendChild(script);
scheduleFlush();
return script;
}
function queueCall(method, args) {
pendingCalls.push({ method, args });
scheduleFlush();
}
function invokeUmami(method, args) {
if (!isBrowserEnvironment()) {
return;
}
ensureAnalyticsLoaded();
const umami = window.umami;
if (umami && typeof umami[method] === "function") {
try {
umami[method].apply(umami, args);
} catch (err) {
console.warn("[analytics] Failed to call", method, err);
}
return;
}
queueCall(method, args);
}
export function trackPageView(path, referrer) {
if (!isBrowserEnvironment()) {
return;
}
const resolvedPath =
typeof path === "string" && path.length > 0
? path
: `${window.location.pathname}${window.location.hash || ""}`;
const resolvedReferrer =
typeof referrer === "string" ? referrer : document.referrer || "";
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
let absoluteUrl = resolvedPath;
try {
absoluteUrl = new URL(resolvedPath, window.location.origin).toString();
} catch (err) {
// Fall back to the provided path if it cannot be resolved.
}
let absoluteReferrer = resolvedReferrer;
if (resolvedReferrer) {
try {
absoluteReferrer = new URL(resolvedReferrer, window.location.origin).toString();
} catch (err) {
// Keep original referrer if it cannot be normalized.
}
}
invokeUmami("track", [
(basePayload = {}) => ({
...basePayload,
url: absoluteUrl || basePayload.url,
referrer: absoluteReferrer || basePayload.referrer,
}),
"pageview",
]);
}
export function trackVideoView({
videoId,
title,
source,
hasMagnet,
hasUrl,
} = {}) {
if (!isBrowserEnvironment()) {
return;
}
const payload = {};
if (videoId) {
payload.videoId = String(videoId);
}
if (title) {
payload.title = String(title);
}
if (source) {
payload.source = String(source);
}
if (typeof hasMagnet === "boolean") {
payload.hasMagnet = hasMagnet;
}
if (typeof hasUrl === "boolean") {
payload.hasUrl = hasUrl;
}
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
invokeUmami("track", [ANALYTICS_CONFIG.videoViewEventName, payload]);
}
// Immediately queue the analytics script so page views are captured early.
if (isBrowserEnvironment()) {
ensureAnalyticsLoaded();
}
+18
View File
@@ -0,0 +1,18 @@
// js/analyticsConfig.js
// Central configuration for site-wide analytics tracking.
export const ANALYTICS_CONFIG = Object.freeze({
/**
* Hosted Umami script that powers analytics.
* Update this value if the tracker is relocated.
*/
scriptSrc: "https://umami.malin.onl/script.js",
/**
* The Umami website identifier for this deployment.
*/
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
/**
* Event name used when recording individual video views.
*/
videoViewEventName: "video_view",
});
+2762 -310
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { Buffer } from "https://esm.sh/[email protected]?bundle";
const globalScope = typeof globalThis !== "undefined" ? globalThis : window;
if (globalScope && !globalScope.Buffer) {
globalScope.Buffer = Buffer;
}
if (globalScope && !globalScope.global) {
globalScope.global = globalScope;
}
if (globalScope && !globalScope.process) {
globalScope.process = { env: {} };
}
export { Buffer };
+161 -65
View File
@@ -1,8 +1,12 @@
// js/channelProfile.js
import { nostrClient } from "./nostr.js";
import {
nostrClient,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
@@ -44,6 +48,8 @@ export async function initChannelProfileView() {
if (btn) btn.classList.add("hidden");
}
setupZapButton();
// 4) Load user’s profile (banner, avatar, etc.)
await loadUserProfile(hexPub);
@@ -51,6 +57,22 @@ export async function initChannelProfileView() {
await loadUserVideos(hexPub);
}
function setupZapButton() {
const zapButton = document.getElementById("zapButton");
if (!zapButton) {
return;
}
if (zapButton.dataset.initialized === "true") {
return;
}
zapButton.addEventListener("click", () => {
window.alert("Zaps coming soon.");
});
zapButton.dataset.initialized = "true";
}
/**
* Renders a Subscribe / Unsubscribe button with an icon,
* using color #fe0032 and the subscribe-button-icon.svg on the left.
@@ -199,14 +221,14 @@ async function loadUserVideos(pubkey) {
// 3) Convert to "video" objects
let videos = [];
for (const evt of events) {
const vid = localConvertEventToVideo(evt);
const vid = sharedConvertEventToVideo(evt);
if (!vid.invalid && !vid.deleted) {
videos.push(vid);
}
}
// 4) Deduplicate older overshadowed versions => newest only
videos = dedupeToNewestByRoot(videos);
videos = app?.dedupeVideosByRoot?.(videos) ?? dedupeToNewestByRoot(videos);
// 5) Filter out blacklisted IDs / authors
videos = videos.filter((video) => {
@@ -251,6 +273,10 @@ async function loadUserVideos(pubkey) {
video.alreadyDecrypted = true;
}
// Ensure the global videos map is kept up to date so delegated handlers
// have the freshest metadata for this event.
window.app?.videosMap?.set(video.id, video);
// Check if user can edit
const canEdit = video.pubkey === app.pubkey;
let hasOlder = false;
@@ -264,6 +290,7 @@ async function loadUserVideos(pubkey) {
class="block w-full text-left px-4 py-2 text-sm text-red-400
hover:bg-red-700 hover:text-white"
data-revert-index="${index}"
data-revert-event-id="${video.id}"
>
Revert
</button>
@@ -298,6 +325,7 @@ async function loadUserVideos(pubkey) {
class="block w-full text-left px-4 py-2 text-sm text-gray-100
hover:bg-gray-700"
data-edit-index="${index}"
data-edit-event-id="${video.id}"
>
Edit
</button>
@@ -306,6 +334,7 @@ async function loadUserVideos(pubkey) {
class="block w-full text-left px-4 py-2 text-sm text-red-400
hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}"
data-delete-all-event-id="${video.id}"
>
Delete All
</button>
@@ -322,6 +351,7 @@ async function loadUserVideos(pubkey) {
const cardEl = document.createElement("div");
cardEl.classList.add(
"video-card",
"bg-gray-900",
"rounded-lg",
"overflow-hidden",
@@ -331,8 +361,42 @@ async function loadUserVideos(pubkey) {
"duration-300"
);
const rawMagnet =
typeof video.magnet === "string" ? video.magnet : "";
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
const playbackUrl =
typeof video.url === "string" ? video.url : "";
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
const playbackMagnet = trimmedMagnet || legacyInfoHash || "";
const magnetProvided = playbackMagnet.length > 0;
const magnetSupported = app.isMagnetUriSupported(playbackMagnet);
const showUnsupportedTorrentBadge =
!trimmedUrl && magnetProvided && !magnetSupported;
const urlBadgeHtml = trimmedUrl
? app.getUrlHealthPlaceholderMarkup({ includeMargin: false })
: "";
const torrentHealthBadgeHtml =
magnetSupported && magnetProvided
? app.getTorrentHealthBadgeMarkup({ includeMargin: false })
: "";
const connectionBadgesHtml =
urlBadgeHtml || torrentHealthBadgeHtml
? `
<div class="mt-3 flex flex-wrap items-center gap-2">
${urlBadgeHtml}${torrentHealthBadgeHtml}
</div>
`
: "";
cardEl.innerHTML = `
<div class="cursor-pointer relative group">
<div
class="cursor-pointer relative group"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
>
<div class="ratio-16-9">
<img
src="${fallbackThumb}"
@@ -341,32 +405,86 @@ async function loadUserVideos(pubkey) {
/>
</div>
</div>
<div class="p-4 flex items-center justify-between">
<div>
<h3
class="text-lg font-bold text-white mb-2 line-clamp-2"
data-play-magnet="${encodeURIComponent(video.magnet)}"
>
${safeTitle}
</h3>
<p class="text-sm text-gray-500">
${new Date(video.created_at * 1000).toLocaleString()}
</p>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-lg font-bold text-white mb-2 line-clamp-2"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
>
${safeTitle}
</h3>
<p class="text-sm text-gray-500">
${new Date(video.created_at * 1000).toLocaleString()}
</p>
</div>
${gearMenu}
</div>
${gearMenu}
${connectionBadgesHtml}
</div>
`;
// Clicking the card => open the video modal
cardEl.addEventListener("click", () => {
app.playVideoByEventId(video.id);
if (showUnsupportedTorrentBadge) {
cardEl.dataset.torrentSupported = "false";
} else if (magnetProvided && magnetSupported) {
cardEl.dataset.torrentSupported = "true";
} else if (cardEl.dataset.torrentSupported) {
delete cardEl.dataset.torrentSupported;
}
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
delete cardEl.dataset.magnet;
}
// Leave the data-play-* attributes empty in the template markup so the raw
// URL/magnet strings can be assigned after parsing without HTML entity
// escaping, keeping this renderer consistent with app.js. The stored URL is
// encoded so it stays intact within data-* attributes, and the click
// handler decodes it while leaving magnets untouched until
// safeDecodeMagnet() runs.
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
interactiveEls.forEach((el) => {
if (!el.dataset) return;
if (trimmedUrl) {
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
} else {
delete el.dataset.playUrl;
}
el.dataset.playMagnet = playbackMagnet || "";
if (magnetProvided) {
el.dataset.torrentSupported = magnetSupported ? "true" : "false";
} else if (el.dataset.torrentSupported) {
delete el.dataset.torrentSupported;
}
});
if (trimmedUrl) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
app.handleUrlHealthBadge({
video,
url: trimmedUrl,
badgeEl,
});
}
}
fragment.appendChild(cardEl);
});
container.appendChild(fragment);
attachHealthBadges(container);
window.app.videoList = container;
window.app.attachVideoListHandler();
// Lazy-load images
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => app.mediaLoader.observe(el));
@@ -389,10 +507,15 @@ async function loadUserVideos(pubkey) {
editBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-edit-index");
const idx = Number.parseInt(idxAttr, 10);
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleEditVideo(idx);
const eventId = btn.getAttribute("data-edit-event-id") || "";
app.handleEditVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
@@ -401,10 +524,15 @@ async function loadUserVideos(pubkey) {
revertBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-revert-index");
const idx = Number.parseInt(idxAttr, 10);
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleRevertVideo(idx);
const eventId = btn.getAttribute("data-revert-event-id") || "";
app.handleRevertVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
@@ -413,10 +541,15 @@ async function loadUserVideos(pubkey) {
deleteAllBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-delete-all-index");
const idx = Number.parseInt(idxAttr, 10);
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleFullDeleteVideo(idx);
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
app.handleFullDeleteVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
} catch (err) {
@@ -446,43 +579,6 @@ function dedupeToNewestByRoot(videos) {
return Array.from(map.values());
}
/**
* Convert raw event => "video" object.
*/
function localConvertEventToVideo(event) {
try {
const content = JSON.parse(event.content || "{}");
const isSupportedVersion = content.version >= 2;
const hasRequiredFields = !!(content.title && content.magnet);
if (!isSupportedVersion) {
return { id: event.id, invalid: true, reason: "version <2" };
}
if (!hasRequiredFields) {
return { id: event.id, invalid: true, reason: "missing title/magnet" };
}
return {
id: event.id,
videoRootId: content.videoRootId || event.id,
version: content.version,
isPrivate: content.isPrivate ?? false,
title: content.title ?? "",
magnet: content.magnet ?? "",
thumbnail: content.thumbnail ?? "",
description: content.description ?? "",
mode: content.mode ?? "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
invalid: false,
};
} catch (err) {
return { id: event.id, invalid: true, reason: "json parse error" };
}
}
/**
* Basic escaping to avoid XSS.
*/
+159
View File
@@ -0,0 +1,159 @@
const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: true, // try URL before magnet in the player
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
WSS_TRACKERS: Object.freeze([
"wss://tracker.openwebtorrent.com",
"wss://tracker.btorrent.xyz",
"wss://tracker.webtorrent.dev",
]),
});
const globalScope = typeof globalThis === "object" && globalThis ? globalThis : undefined;
const runtimeFlags = (() => {
if (globalScope && typeof globalScope.__BITVID_RUNTIME_FLAGS__ === "object") {
return globalScope.__BITVID_RUNTIME_FLAGS__;
}
const initial = {
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
};
if (globalScope) {
globalScope.__BITVID_RUNTIME_FLAGS__ = initial;
}
return initial;
})();
function coerceBoolean(value, fallback) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return fallback;
}
if (["false", "0", "off", "no"].includes(normalized)) {
return false;
}
if (["true", "1", "on", "yes"].includes(normalized)) {
return true;
}
}
if (value == null) {
return fallback;
}
return Boolean(value);
}
function sanitizeTrackerList(candidate) {
const input = Array.isArray(candidate) ? candidate : DEFAULT_FLAGS.WSS_TRACKERS;
const seen = new Set();
const sanitized = [];
for (const tracker of input) {
if (typeof tracker !== "string") {
continue;
}
const trimmed = tracker.trim();
if (!trimmed) {
continue;
}
if (!/^wss:\/\//i.test(trimmed)) {
continue;
}
const normalized = trimmed.toLowerCase();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
sanitized.push(trimmed);
}
if (!sanitized.length) {
return [...DEFAULT_FLAGS.WSS_TRACKERS];
}
return sanitized;
}
function freezeTrackers(list) {
return Object.freeze([...list]);
}
export let URL_FIRST_ENABLED = coerceBoolean(
runtimeFlags.URL_FIRST_ENABLED,
DEFAULT_FLAGS.URL_FIRST_ENABLED
);
export let ACCEPT_LEGACY_V1 = coerceBoolean(
runtimeFlags.ACCEPT_LEGACY_V1,
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
);
export let WSS_TRACKERS = freezeTrackers(
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
);
Object.defineProperty(runtimeFlags, "URL_FIRST_ENABLED", {
configurable: true,
enumerable: true,
get() {
return URL_FIRST_ENABLED;
},
set(next) {
URL_FIRST_ENABLED = coerceBoolean(next, DEFAULT_FLAGS.URL_FIRST_ENABLED);
},
});
Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
configurable: true,
enumerable: true,
get() {
return ACCEPT_LEGACY_V1;
},
set(next) {
ACCEPT_LEGACY_V1 = coerceBoolean(next, DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
},
});
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
configurable: true,
enumerable: true,
get() {
return [...WSS_TRACKERS];
},
set(next) {
WSS_TRACKERS = freezeTrackers(sanitizeTrackerList(next));
},
});
// Ensure the runtime object reflects the sanitized defaults immediately.
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
export function setUrlFirstEnabled(next) {
runtimeFlags.URL_FIRST_ENABLED = next;
return URL_FIRST_ENABLED;
}
export function setAcceptLegacyV1(next) {
runtimeFlags.ACCEPT_LEGACY_V1 = next;
return ACCEPT_LEGACY_V1;
}
export function setWssTrackers(next) {
runtimeFlags.WSS_TRACKERS = next;
return WSS_TRACKERS;
}
export function resetRuntimeFlags() {
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
}
export const RUNTIME_FLAGS = runtimeFlags;
+18 -6
View File
@@ -2,17 +2,29 @@
class DisclaimerModal {
constructor() {
// Initialize elements when the disclaimer HTML is in the DOM.
this.init();
this.modal = null;
this.acceptButton = null;
this.acceptHandler = null;
}
init() {
this.modal = document.getElementById("disclaimerModal");
this.acceptButton = document.getElementById("acceptDisclaimer");
if (this.acceptButton) {
this.acceptButton.addEventListener("click", () => {
const nextAcceptButton = document.getElementById("acceptDisclaimer");
if (!this.acceptHandler) {
this.acceptHandler = () => {
this.hide();
});
};
}
if (this.acceptButton && this.acceptHandler) {
this.acceptButton.removeEventListener("click", this.acceptHandler);
}
this.acceptButton = nextAcceptButton || null;
if (this.acceptButton && this.acceptHandler) {
this.acceptButton.addEventListener("click", this.acceptHandler);
}
}
+442
View File
@@ -0,0 +1,442 @@
import { infoHashFromMagnet } from "./magnets.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
const containerState = new WeakMap();
const ROOT_MARGIN = "200px 0px";
function now() {
return Date.now();
}
function getCacheEntry(key) {
if (!key) {
return null;
}
const entry = probeCache.get(key);
if (!entry) {
return null;
}
if (now() - entry.ts > PROBE_CACHE_TTL_MS) {
probeCache.delete(key);
return null;
}
return entry.value;
}
function setCacheEntry(key, value) {
if (!key) {
return;
}
probeCache.set(key, { ts: now(), value });
}
function formatTime(ts) {
if (!Number.isFinite(ts)) {
return "";
}
try {
return new Date(ts).toLocaleTimeString([], { hour12: false });
} catch (err) {
return new Date(ts).toLocaleTimeString();
}
}
function buildTooltip({ peers = 0, checkedAt, reason } = {}) {
const parts = [];
if (Number.isFinite(peers)) {
parts.push(`Peers: ${Math.max(0, peers)}`);
}
if (Number.isFinite(checkedAt)) {
const formatted = formatTime(checkedAt);
if (formatted) {
parts.push(`Checked ${formatted}`);
}
}
if (typeof reason === "string" && reason && reason !== "peer") {
let normalizedReason;
if (reason === "timeout") {
normalizedReason = "Timed out";
} else if (reason === "no-trackers") {
normalizedReason = "No WSS trackers";
} else if (reason === "invalid") {
normalizedReason = "Invalid magnet";
} else {
normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1);
}
parts.push(normalizedReason);
}
if (!parts.length) {
return "WebTorrent status unknown";
}
return `WebTorrent • ${parts.join(" • ")}`;
}
function normalizeResult(result) {
const fallback = {
healthy: false,
peers: 0,
reason: "error",
appendedTrackers: false,
hasProbeTrackers: false,
usedTrackers: Array.isArray(TorrentClient.PROBE_TRACKERS)
? [...TorrentClient.PROBE_TRACKERS]
: [],
durationMs: 0,
};
if (!result || typeof result !== "object") {
return { ...fallback, checkedAt: now() };
}
const peers = Number.isFinite(result.peers)
? Math.max(0, Number(result.peers))
: 0;
const healthy = Boolean(result.healthy) && peers > 0;
const reason = typeof result.reason === "string" ? result.reason : "error";
return {
healthy,
peers: healthy ? Math.max(1, peers) : peers,
reason,
appendedTrackers: Boolean(result.appendedTrackers),
hasProbeTrackers:
typeof result.hasProbeTrackers === "boolean"
? result.hasProbeTrackers
: healthy || Boolean(result.appendedTrackers),
usedTrackers: Array.isArray(result.usedTrackers)
? result.usedTrackers.slice()
: fallback.usedTrackers,
durationMs: Number.isFinite(result.durationMs)
? Math.max(0, Number(result.durationMs))
: 0,
checkedAt: now(),
};
}
function queueProbe(magnet, cacheKey) {
if (!magnet) {
return Promise.resolve(null);
}
const cached = getCacheEntry(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
if (probeInflight.has(cacheKey)) {
return probeInflight.get(cacheKey);
}
const job = probeQueue
.run(() =>
torrentClient
.probePeers(magnet, {
timeoutMs: PROBE_TIMEOUT_MS,
maxWebConns: 2,
polls: PROBE_POLL_COUNT,
})
.catch((err) => ({
healthy: false,
peers: 0,
reason: "error",
error: err,
appendedTrackers: false,
hasProbeTrackers: false,
}))
)
.then((result) => {
const normalized = normalizeResult(result);
if (cacheKey) {
setCacheEntry(cacheKey, normalized);
}
return normalized;
})
.finally(() => {
if (cacheKey) {
probeInflight.delete(cacheKey);
}
});
if (cacheKey) {
probeInflight.set(cacheKey, job);
}
return job;
}
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
const PROBE_CONCURRENCY = 3;
const PROBE_POLL_COUNT = 3;
class ProbeQueue {
constructor(max = 2) {
this.max = Math.max(1, Number(max) || 1);
this.running = 0;
this.queue = [];
}
run(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.drain();
});
}
drain() {
if (this.running >= this.max) {
return;
}
const job = this.queue.shift();
if (!job) {
return;
}
this.running += 1;
let finished = false;
const finalize = () => {
if (finished) {
return;
}
finished = true;
this.running -= 1;
this.drain();
};
let result;
try {
result = job.task();
} catch (err) {
job.reject(err);
finalize();
return;
}
Promise.resolve(result)
.then((value) => {
job.resolve(value);
})
.catch((err) => {
job.reject(err);
})
.finally(() => {
finalize();
});
}
}
const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
const probeCache = new Map();
const probeInflight = new Map();
function ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const card = entry.target;
if (!(card instanceof HTMLElement)) {
return;
}
if (!entry.isIntersecting) {
return;
}
handleCardVisible({ card, pendingByCard });
});
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
state = { observer, pendingByCard, observedCards };
containerState.set(container, state);
return state;
}
function setBadge(card, state, details) {
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
return;
}
const hadMargin = badge.classList.contains("mt-3");
const baseClasses = [
"torrent-health-badge",
"text-xs",
"font-semibold",
"px-2",
"py-1",
"rounded",
"inline-flex",
"items-center",
"gap-1",
"transition-colors",
"duration-200",
];
if (hadMargin) {
baseClasses.unshift("mt-3");
}
badge.className = baseClasses.join(" ");
const map = {
healthy: {
icon: "🟢",
aria: "WebTorrent peers available",
classes: ["bg-green-900", "text-green-200"],
role: "status",
},
unhealthy: {
icon: "🔴",
aria: "WebTorrent peers unavailable",
classes: ["bg-red-900", "text-red-200"],
role: "alert",
},
checking: {
icon: "⏳",
aria: "Checking WebTorrent peers",
classes: ["bg-gray-800", "text-gray-300"],
role: "status",
},
unknown: {
icon: "⚪",
aria: "WebTorrent status unknown",
classes: ["bg-gray-800", "text-gray-300"],
role: "status",
},
};
const entry = map[state] || map.unknown;
entry.classes.forEach((cls) => badge.classList.add(cls));
const peers =
details && Number.isFinite(details.peers)
? Math.max(0, Number(details.peers))
: 0;
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
const tooltip =
state === "checking" || state === "unknown"
? entry.aria
: buildTooltip({
peers,
checkedAt: details?.checkedAt,
reason: details?.reason,
});
badge.setAttribute("aria-label", tooltip);
badge.setAttribute("title", tooltip);
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
badge.setAttribute("role", entry.role);
badge.dataset.streamHealthState = state;
if (Number.isFinite(peers)) {
badge.dataset.streamHealthPeers = String(peers);
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
}
function handleCardVisible({ card, pendingByCard }) {
if (!(card instanceof HTMLElement)) {
return;
}
const magnet = card.dataset.magnet || "";
if (!magnet) {
setBadge(card, "unknown");
return;
}
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
setBadge(card, "unknown");
return;
}
if (pendingByCard.has(card)) {
return;
}
const cached = getCacheEntry(infoHash);
if (cached) {
const cachedState = cached.healthy
? "healthy"
: cached.hasProbeTrackers
? "unhealthy"
: "unknown";
setBadge(card, cachedState, cached);
return;
}
setBadge(card, "checking");
const probePromise = queueProbe(magnet, infoHash);
pendingByCard.set(card, probePromise);
probePromise
.then((result) => {
pendingByCard.delete(card);
if (!card.isConnected) {
return;
}
if (!result) {
setBadge(card, "unknown");
return;
}
if (!result.hasProbeTrackers) {
setBadge(card, "unknown", result);
return;
}
if (result.healthy) {
setBadge(card, "healthy", result);
return;
}
setBadge(card, "unhealthy", result);
})
.catch((err) => {
console.warn("[gridHealth] probe failed", err);
pendingByCard.delete(card);
if (!card.isConnected) {
return;
}
setBadge(card, "unhealthy");
});
}
export function attachHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = ensureState(container);
const cards = container.querySelectorAll(".video-card");
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
if (!card.dataset.magnet) {
setBadge(card, "unknown");
}
});
}
export function refreshHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = containerState.get(container);
if (!state) {
return;
}
state.observer.takeRecords().forEach((entry) => {
if (entry.isIntersecting) {
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
}
});
}
+98
View File
@@ -0,0 +1,98 @@
import PQueue from "https://esm.sh/[email protected]";
import { trackerPing } from "./trackerPing.js";
import { infoHashFromMagnet } from "./magnets.js";
import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js";
const queue = new PQueue({ concurrency: CONCURRENCY });
const cache = new Map();
const inflight = new Map();
export function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
export function getHealthCached(infoHash) {
if (!infoHash) {
return null;
}
const entry = cache.get(infoHash);
if (!entry) {
return null;
}
if (Date.now() - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
return null;
}
return entry.value;
}
export function setHealthCache(infoHash, value) {
if (!infoHash) {
return;
}
cache.set(infoHash, { ts: Date.now(), value });
}
export function queueHealthCheck(magnet, onResult) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
const fallback = getDefaultHealth();
if (typeof onResult === "function") {
onResult(fallback);
}
return Promise.resolve(fallback);
}
const cached = getHealthCached(infoHash);
if (cached) {
if (typeof onResult === "function") {
onResult(cached);
}
return Promise.resolve(cached);
}
if (inflight.has(infoHash)) {
const pending = inflight.get(infoHash);
if (typeof onResult === "function") {
pending.then(onResult).catch(() => {});
}
return pending;
}
const jobPromise = queue
.add(async () => {
try {
const health = await trackerPing(magnet);
setHealthCache(infoHash, health);
return health;
} catch (err) {
console.warn("trackerPing failed", err);
return getDefaultHealth();
}
})
.finally(() => {
inflight.delete(infoHash);
});
inflight.set(infoHash, jobPromise);
if (typeof onResult === "function") {
jobPromise.then(onResult).catch(() => {});
}
return jobPromise;
}
export function purgeHealthCache() {
const now = Date.now();
Array.from(cache.keys()).forEach((infoHash) => {
const entry = cache.get(infoHash);
if (!entry || now - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
}
});
}
+78 -10
View File
@@ -1,5 +1,64 @@
// js/index.js
import "./bufferPolyfill.js";
import { trackPageView } from "./analytics.js";
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
//
// Centralized animation cleanup
// ------------------------------
// We attach a single capture-phase listener for animation events so that any
// chrome element or thumbnail using our fade-in helpers can automatically shed
// the temporary classes/data attributes that trigger the transition. This is
// critical because these DOM nodes frequently persist while the surrounding
// lists re-render; if the classes stick around, future layout shuffles would
// replay the fade and make the UI flicker. Clearing the hooks immediately after
// the first animation keeps the graceful entrance without introducing future
// flashes.
const handleFadeInAnimationComplete = (event) => {
const { animationName, target } = event;
if (!(target instanceof HTMLElement)) {
return;
}
if (animationName === INTERFACE_FADE_IN_ANIMATION) {
if (!target.classList.contains("fade-in")) {
return;
}
// Remove the transient fade-in class so future renders keep the element
// visible. The delays piggyback on `fade-in`, so we strip those too.
target.classList.remove("fade-in");
Array.from(target.classList).forEach((className) => {
if (className.startsWith("fade-in-delay-")) {
target.classList.remove(className);
}
});
return;
}
if (animationName !== VIDEO_THUMBNAIL_FADE_IN_ANIMATION) {
return;
}
if (
target.dataset &&
target.dataset.videoThumbnail === "true" &&
target.dataset.thumbnailLoaded === "true"
) {
// Thumbnails use the same pattern: once the fade finishes we clear the flag
// so a later `load` event (or template reuse) does not restart the
// animation.
delete target.dataset.thumbnailLoaded;
}
};
document.addEventListener("animationend", handleFadeInAnimationComplete, true);
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
// 1) Load modals (login, application, etc.)
async function loadModal(url) {
try {
@@ -8,9 +67,14 @@ async function loadModal(url) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
const sanitizedHtml = html.replace(
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
""
);
document
.getElementById("modalContainer")
.insertAdjacentHTML("beforeend", html);
.insertAdjacentHTML("beforeend", sanitizedHtml);
console.log(url, "loaded");
} catch (err) {
console.error(err);
@@ -171,6 +235,12 @@ Promise.all([
// Also run once on initial load
handleHashChange();
return import("./disclaimer.js");
})
.then(({ default: disclaimerModal }) => {
disclaimerModal.init();
disclaimerModal.show();
});
/* -------------------------------------------
@@ -292,9 +362,6 @@ function handleQueryParams() {
}
}
/**
* Handle #view=... in the hash and load the correct partial view.
*/
function handleHashChange() {
console.log("handleHashChange called, current hash =", window.location.hash);
@@ -311,6 +378,7 @@ function handleHashChange() {
if (typeof initFn === "function") {
initFn();
}
recordView("most-recent-videos");
});
});
return;
@@ -321,11 +389,11 @@ function handleHashChange() {
// Now dynamically load that partial, then call its init function
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
loadView(viewUrl).then(() => {
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
initFn();
}
});
loadView(viewUrl).then(() => {
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
initFn();
}
});
});
}
+1
View File
@@ -13,6 +13,7 @@ const npubs = [
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
"npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex
];
console.log("DEBUG: lists.js loaded, npubs:", npubs);
+170
View File
@@ -0,0 +1,170 @@
import { WSS_TRACKERS } from "./constants.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
function getOriginProtocol() {
if (typeof window !== "undefined" && window.location?.protocol) {
return window.location.protocol.toLowerCase();
}
return "https:";
}
function decodeLoose(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
return decodeURIComponent(trimmed);
} catch (err) {
return trimmed;
}
}
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/\/+$/, "").toLowerCase();
}
function tryFormatAbsoluteUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch (err) {
return trimmed;
}
}
/**
* Normalize and enrich a magnet string entered in the upload form so that it
* always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of
* the legacy magnetUtils helper but returns a plain string for ease of use.
*/
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
if (!initial) {
return "";
}
let working = initial;
if (HEX_INFO_HASH.test(working)) {
working = `magnet:?xt=urn:btih:${working.toLowerCase()}`;
}
working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`);
if (!/^magnet:/i.test(working)) {
return working;
}
let fragment = "";
const hashIndex = working.indexOf("#");
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const [, queryPart = ""] = working.split("?", 2);
const normalizedScheme = "magnet:";
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
for (const rawParam of rawParams) {
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
const key = rawKey.trim();
if (!key) {
continue;
}
let value = rawVal.trim();
if (key.toLowerCase() === "xt" && value) {
const decoded = decodeLoose(value);
if (decoded) {
value = decoded;
}
}
params.push({
key,
value,
lowerKey: key.toLowerCase(),
compareValue: normalizeForComparison(value),
});
}
const appendUniqueParam = (key, value) => {
if (!value) {
return;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return;
}
const lowerKey = key.toLowerCase();
const compareValue = normalizeForComparison(trimmedValue);
if (compareValue) {
const exists = params.some(
(param) => param.lowerKey === lowerKey && param.compareValue === compareValue
);
if (exists) {
return;
}
}
params.push({
key,
value: trimmedValue,
lowerKey,
compareValue,
});
};
for (const tracker of WSS_TRACKERS) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
appendUniqueParam("tr", trimmedTracker);
}
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
if (normalizedXs) {
appendUniqueParam("xs", normalizedXs);
}
const originProtocol = getOriginProtocol();
const rawWs = typeof ws === "string" ? ws.trim() : "";
if (rawWs) {
const formattedWs = tryFormatAbsoluteUrl(rawWs);
const lowerWs = formattedWs.toLowerCase();
const isHttpSeed = lowerWs.startsWith("http://");
const allowHttpSeed = originProtocol === "http:";
if (!isHttpSeed || allowHttpSeed) {
appendUniqueParam("ws", formattedWs);
}
}
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
}
+309
View File
@@ -0,0 +1,309 @@
// js/magnetUtils.js
import { WSS_TRACKERS } from "./constants.js";
export { WSS_TRACKERS };
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const BTIH_PREFIX = "urn:btih:";
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(/\/?$/, "");
const normalizedPath = pathname ? pathname : "";
return (
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
.trim()
.toLowerCase()
);
} catch (err) {
return trimmed.replace(/\/?$/, "").toLowerCase();
}
}
function sanitizeHttpUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
} catch (err) {
return "";
}
return "";
}
export function safeDecodeMagnet(value) {
if (typeof value !== "string") {
return "";
}
let decoded = value.trim();
if (!decoded) {
return "";
}
for (let i = 0; i < 2; i += 1) {
if (!decoded.includes("%")) {
break;
}
try {
const candidate = decodeURIComponent(decoded);
if (!candidate) {
break;
}
if (candidate === decoded) {
break;
}
decoded = candidate.trim();
} catch (err) {
break;
}
}
return decoded;
}
/**
* Normalize a magnet URI while preserving legacy payload quirks and augmenting it
* for browser playback.
*
* Key behaviors:
* - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links.
* - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization
* beyond decoding legacy `%3A` segments.
* - Only appends browser-safe WSS trackers in addition to whatever the caller provides.
* - Skips insecure `http:` web seeds unless the application itself is running over HTTP.
*
* Note: `didChange` may be reported even if the resulting magnet string is textually
* identical to the input. Callers should not rely on `didChange` to detect a modified
* URI.
*/
export function normalizeAndAugmentMagnet(
rawValue,
{
webSeed,
torrentUrl,
xs,
extraTrackers = [],
logger,
appProtocol,
} = {}
) {
const log = typeof logger === "function" ? logger : () => {};
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
if (!initial) {
return { magnet: "", didChange: false };
}
let working = initial;
let didMutate = false;
const bareHashMatch = HEX_INFO_HASH.test(working);
if (bareHashMatch) {
working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
didMutate = true;
}
const hashIndex = working.indexOf("#");
let fragment = "";
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
didMutate = true;
return `xt=${BTIH_PREFIX}${hash}`;
});
working = decodedXt;
if (!/^magnet:/i.test(working)) {
return {
magnet: working,
didChange: didMutate || working !== initial,
};
}
const [schemePart, queryPart = ""] = working.split("?", 2);
const normalizedScheme = "magnet:";
if (schemePart !== normalizedScheme) {
didMutate = true;
}
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
const decodeLoose = (value) => {
if (typeof value !== "string" || !value) {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
};
for (const rawParam of rawParams) {
const [rawKey, rawValue = ""] = rawParam.split("=", 2);
const key = rawKey.trim();
if (!key) {
continue;
}
let value = rawValue.trim();
if (key.toLowerCase() === "xt" && value) {
const decoded = decodeLoose(value);
if (decoded !== value) {
value = decoded;
didMutate = true;
}
}
params.push({
key,
value,
decoded: decodeLoose(value),
});
}
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
? torrentUrl
: typeof xs === "string"
? xs.trim()
: "";
const safeTorrentHint = sanitizeHttpUrl(torrentHint);
const seedInputs = Array.isArray(webSeed)
? webSeed
: typeof webSeed === "string"
? [webSeed]
: [];
const resolvedProtocol = typeof appProtocol === "string" && appProtocol
? appProtocol.toLowerCase()
: typeof window !== "undefined" && window.location && window.location.protocol
? window.location.protocol.toLowerCase()
: "https:";
const existingTrackers = new Set(
params
.filter((param) => param.key.toLowerCase() === "tr")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
const trackerCandidates = [
...WSS_TRACKERS,
...extraTrackers,
];
for (const tracker of trackerCandidates) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!trimmedTracker) {
continue;
}
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
const normalizedTracker = normalizeForComparison(trimmedTracker);
if (!normalizedTracker || existingTrackers.has(normalizedTracker)) {
continue;
}
params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker });
existingTrackers.add(normalizedTracker);
didMutate = true;
}
if (safeTorrentHint) {
const existingXs = new Set(
params
.filter((param) => param.key.toLowerCase() === "xs")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
const normalizedXs = normalizeForComparison(safeTorrentHint);
if (normalizedXs && !existingXs.has(normalizedXs)) {
params.push({
key: "xs",
value: safeTorrentHint,
decoded: safeTorrentHint,
});
didMutate = true;
}
}
if (seedInputs.length) {
const existingWs = new Set(
params
.filter((param) => param.key.toLowerCase() === "ws")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
for (const seedInput of seedInputs) {
if (typeof seedInput !== "string") {
continue;
}
const trimmedSeed = seedInput.trim();
if (!trimmedSeed) {
continue;
}
try {
const parsedSeed = new URL(trimmedSeed);
const seedProtocol = parsedSeed.protocol;
const allowHttpSeed = resolvedProtocol === "http:";
if (
seedProtocol === "https:" ||
(seedProtocol === "http:" && allowHttpSeed)
) {
const seedValue = parsedSeed.toString();
const normalizedSeed = normalizeForComparison(seedValue);
if (normalizedSeed && !existingWs.has(normalizedSeed)) {
params.push({ key: "ws", value: seedValue, decoded: seedValue });
existingWs.add(normalizedSeed);
didMutate = true;
}
} else if (seedProtocol === "http:") {
log(
`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`
);
}
} catch (err) {
// Ignore invalid web seed values silently.
}
}
}
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
return {
magnet: finalMagnet,
didChange: didMutate || finalMagnet !== initial,
};
}
+66
View File
@@ -0,0 +1,66 @@
import parseMagnet from "https://esm.sh/[email protected]";
function normalizeInfoHash(candidate) {
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
try {
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to normalize base32 info hash", err);
}
}
return null;
}
export function infoHashFromMagnet(magnet) {
if (typeof magnet !== "string") {
return null;
}
const direct = normalizeInfoHash(magnet);
if (direct) {
return direct;
}
try {
const parsed = parseMagnet(magnet);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to parse magnet for info hash", err);
return null;
}
}
export function trackersFromMagnet(magnet) {
if (typeof magnet !== "string") {
return [];
}
try {
const parsed = parseMagnet(magnet);
if (!parsed || !Array.isArray(parsed.announce)) {
return [];
}
const deduped = new Set();
parsed.announce.forEach((url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
deduped.add(trimmed);
});
return Array.from(deduped);
} catch (err) {
console.warn("Failed to parse magnet trackers", err);
return [];
}
}
+518 -58
View File
@@ -1,7 +1,10 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { ACCEPT_LEGACY_V1 } from "./constants.js";
import { accessControl } from "./accessControl.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
/**
* The usual relays
@@ -14,6 +17,10 @@ const RELAY_URLS = [
"wss://relay.nostr.band",
];
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
// To limit error spam
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
@@ -43,53 +50,256 @@ function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event => your "video" object.
* CHANGED: skip if version <2
*/
function convertEventToVideo(event) {
const EXTENSION_MIME_MAP = {
mp4: "video/mp4",
m4v: "video/x-m4v",
webm: "video/webm",
mkv: "video/x-matroska",
mov: "video/quicktime",
avi: "video/x-msvideo",
ogv: "video/ogg",
ogg: "video/ogg",
m3u8: "application/x-mpegURL",
mpd: "application/dash+xml",
ts: "video/mp2t",
mpg: "video/mpeg",
mpeg: "video/mpeg",
flv: "video/x-flv",
"3gp": "video/3gpp",
};
function inferMimeTypeFromUrl(url) {
if (!url || typeof url !== "string") {
return "";
}
let pathname = "";
try {
const content = JSON.parse(event.content || "{}");
const parsed = new URL(url);
pathname = parsed.pathname || "";
} catch (err) {
const sanitized = url.split("?")[0].split("#")[0];
pathname = sanitized || "";
}
// Example checks:
const isSupportedVersion = content.version >= 2;
const hasRequiredFields = !!(content.title && content.magnet);
const lastSegment = pathname.split("/").pop() || "";
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
if (!match) {
return "";
}
if (!isSupportedVersion) {
return {
id: event.id,
invalid: true,
reason: "version <2",
};
const extension = match[1].toLowerCase();
return EXTENSION_MIME_MAP[extension] || "";
}
/**
* Convert a raw Nostr event into Bitvid's canonical "video" object.
*
* The converter intentionally centralises all of the quirky legacy handling so
* that feed rendering, subscriptions, and deep links rely on the exact same
* rules. Any future regression around magnet-only posts or malformed JSON
* should be solved by updating this function (and its tests) instead of
* sprinkling ad-hoc checks elsewhere in the UI.
*
* Also accepts legacy (<v2) payloads when ACCEPT_LEGACY_V1 allows it.
*/
function convertEventToVideo(event = {}) {
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
const rawContent = typeof event.content === "string" ? event.content : "";
const tags = Array.isArray(event.tags) ? event.tags : [];
let parsedContent = {};
let parseError = null;
if (rawContent) {
try {
const parsed = JSON.parse(rawContent);
if (parsed && typeof parsed === "object") {
parsedContent = parsed;
}
} catch (err) {
parseError = err;
parsedContent = {};
}
if (!hasRequiredFields) {
return {
id: event.id,
invalid: true,
reason: "missing title/magnet",
};
}
const directUrl = safeTrim(parsedContent.url);
const directMagnetRaw = safeTrim(parsedContent.magnet);
const normalizeMagnetCandidate = (value) => {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.toLowerCase().startsWith("magnet:?")) {
return trimmed;
}
const extracted = magnetFromText(trimmed);
return extracted ? extracted.trim() : "";
};
let magnet = normalizeMagnetCandidate(directMagnetRaw);
let rawMagnet = magnet ? directMagnetRaw : "";
if (!magnet && ACCEPT_LEGACY_V1) {
const inlineMagnet = normalizeMagnetCandidate(rawContent);
if (inlineMagnet) {
magnet = inlineMagnet;
}
if (!magnet) {
outer: for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const key =
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
const startIndex = key === "magnet" ? 1 : 0;
for (let i = startIndex; i < tag.length; i += 1) {
const candidate = normalizeMagnetCandidate(tag[i]);
if (candidate) {
magnet = candidate;
break outer;
}
}
}
}
if (!magnet) {
const recoveredFromRaw = magnetFromText(rawContent);
if (recoveredFromRaw) {
magnet = safeTrim(recoveredFromRaw);
}
}
}
if (!rawMagnet && magnet) {
rawMagnet = magnet;
}
const url = directUrl;
if (!url && !magnet) {
return { id: event.id, invalid: true, reason: "missing playable source" };
}
const thumbnail = safeTrim(parsedContent.thumbnail);
const description = safeTrim(parsedContent.description);
const rawMode = safeTrim(parsedContent.mode);
const mode = rawMode || "live";
const deleted = parsedContent.deleted === true;
const isPrivate = parsedContent.isPrivate === true;
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
let infoHash = "";
const pushInfoHash = (candidate) => {
if (typeof candidate !== "string") {
return false;
}
const normalized = candidate.trim().toLowerCase();
if (/^[0-9a-f]{40}$/.test(normalized)) {
infoHash = normalized;
return true;
}
return false;
};
pushInfoHash(parsedContent.infoHash);
if (!infoHash && magnet) {
const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i);
if (match && match[1]) {
pushInfoHash(match[1]);
}
}
const searchInfoHashInString = (value) => {
if (infoHash || typeof value !== "string") {
return;
}
const match = value.match(/[0-9a-f]{40}/i);
if (match && match[0]) {
pushInfoHash(match[0]);
}
};
if (!infoHash && ACCEPT_LEGACY_V1) {
searchInfoHashInString(rawContent);
for (const tag of tags) {
if (infoHash) {
break;
}
if (!Array.isArray(tag)) {
continue;
}
for (let i = 0; i < tag.length; i += 1) {
searchInfoHashInString(tag[i]);
if (infoHash) {
break;
}
}
}
}
const declaredTitle = safeTrim(parsedContent.title);
const derivedTitle = deriveTitleFromEvent({
parsedContent,
tags,
primaryTitle: declaredTitle,
});
let title = safeTrim(derivedTitle);
if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) {
title = infoHash
? `Legacy Video ${infoHash.slice(0, 8)}`
: "Legacy BitTorrent Video";
}
if (!title) {
const reason = parseError
? "missing title (json parse error)"
: "missing title";
return { id: event.id, invalid: true, reason };
}
const rawVersion = parsedContent.version;
let version = rawVersion === undefined ? 2 : Number(rawVersion);
if (!Number.isFinite(version)) {
version = rawVersion === undefined ? 2 : 1;
}
if (version < 2 && !ACCEPT_LEGACY_V1) {
return {
id: event.id,
videoRootId: content.videoRootId || event.id,
version: content.version,
isPrivate: content.isPrivate ?? false,
title: content.title ?? "",
magnet: content.magnet ?? "",
thumbnail: content.thumbnail ?? "",
description: content.description ?? "",
mode: content.mode ?? "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
invalid: false,
invalid: true,
reason: `unsupported version ${version}`,
};
} catch (err) {
// JSON parse error
return { id: event.id, invalid: true, reason: "json parse error" };
}
return {
id: event.id,
videoRootId,
version,
isPrivate,
title,
url,
magnet,
rawMagnet,
infoHash,
thumbnail,
description,
mode,
deleted,
pubkey: event.pubkey,
created_at: event.created_at,
tags,
invalid: false,
};
}
/**
@@ -107,6 +317,8 @@ function getActiveKey(video) {
return `LEGACY:${video.id}`;
}
export { convertEventToVideo };
class NostrClient {
constructor() {
this.pool = null;
@@ -118,6 +330,107 @@ class NostrClient {
// “activeMap” holds only the newest version for each root
this.activeMap = new Map();
this.hasRestoredLocalData = false;
}
restoreLocalData() {
if (this.hasRestoredLocalData) {
return this.allEvents.size > 0;
}
this.hasRestoredLocalData = true;
if (typeof localStorage === "undefined") {
return false;
}
const now = Date.now();
const parsePayload = (raw) => {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to parse cached events:", err);
}
}
return null;
};
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
if (!payload) {
const legacyRaw = localStorage.getItem(LEGACY_EVENTS_STORAGE_KEY);
const legacyParsed = parsePayload(legacyRaw);
if (legacyParsed) {
payload = {
version: 1,
savedAt: now,
events: legacyParsed,
};
}
if (legacyRaw) {
try {
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to remove legacy cache:", err);
}
}
}
}
if (!payload || payload.version !== 1) {
return false;
}
if (
typeof payload.savedAt !== "number" ||
payload.savedAt <= 0 ||
now - payload.savedAt > EVENTS_CACHE_TTL_MS
) {
try {
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to clear expired cache:", err);
}
}
return false;
}
const events = payload.events;
if (!events || typeof events !== "object") {
return false;
}
this.allEvents.clear();
this.activeMap.clear();
for (const [id, video] of Object.entries(events)) {
if (!id || !video || typeof video !== "object") {
continue;
}
this.allEvents.set(id, video);
if (video.deleted) {
continue;
}
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
return this.allEvents.size > 0;
}
/**
@@ -126,6 +439,8 @@ class NostrClient {
async init() {
if (isDevMode) console.log("Connecting to relays...");
this.restoreLocalData();
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
@@ -144,6 +459,11 @@ class NostrClient {
}
}
// We subscribe to kind `0` purely as a liveness probe because almost every
// relay can answer it quickly. Either an `event` or `eose` signals success,
// while the 5s timer guards against relays that never respond. We immediately
// `unsub` to avoid leaking subscriptions. Note: any future change must still
// provide a lightweight readiness check with similar timeout semantics.
async connectToRelays() {
return Promise.all(
this.relays.map(
@@ -212,8 +532,7 @@ class NostrClient {
}
/**
* Publish a new video
* CHANGED: Force version=2 for all new notes
* Publish a new video using the v3 content schema.
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
@@ -222,31 +541,49 @@ class NostrClient {
console.log("Publishing new video with data:", videoData);
}
let finalMagnet = videoData.magnet;
if (videoData.isPrivate) {
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
let finalMagnet = rawMagnet.trim();
if (videoData.isPrivate && finalMagnet) {
finalMagnet = fakeEncrypt(finalMagnet);
}
const finalUrl =
typeof videoData.url === "string" ? videoData.url.trim() : "";
const finalThumbnail =
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
const finalDescription =
typeof videoData.description === "string"
? videoData.description.trim()
: "";
const finalTitle =
typeof videoData.title === "string" ? videoData.title.trim() : "";
const providedMimeType =
typeof videoData.mimeType === "string"
? videoData.mimeType.trim()
: "";
const createdAt = Math.floor(Date.now() / 1000);
// brand-new root & d
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const contentObject = {
version: 3,
title: finalTitle,
url: finalUrl,
magnet: finalMagnet,
thumbnail: finalThumbnail,
description: finalDescription,
mode: videoData.mode || "live",
videoRootId,
version: 2, // forcibly set version=2
deleted: false,
isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "",
magnet: finalMagnet,
thumbnail: videoData.thumbnail || "",
description: videoData.description || "",
mode: videoData.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
created_at: createdAt,
tags: [
["t", "video"],
["d", dTagValue],
@@ -273,6 +610,83 @@ class NostrClient {
}
})
);
if (finalUrl) {
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
const mimeType =
providedMimeType || inferredMimeType || "application/octet-stream";
const mirrorTags = [
["url", finalUrl],
["m", mimeType],
];
if (finalThumbnail) {
mirrorTags.push(["thumb", finalThumbnail]);
}
const altText = finalDescription || finalTitle || "";
if (altText) {
mirrorTags.push(["alt", altText]);
}
if (!contentObject.isPrivate && finalMagnet) {
mirrorTags.push(["magnet", finalMagnet]);
}
const mirrorEvent = {
kind: 1063,
pubkey,
created_at: createdAt,
tags: mirrorTags,
content: altText,
};
if (isDevMode) {
console.log("Prepared NIP-94 mirror event:", mirrorEvent);
}
try {
const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent);
if (isDevMode) {
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedMirrorEvent);
if (isDevMode) {
console.log(`NIP-94 mirror published to ${url}`);
}
} catch (mirrorErr) {
if (isDevMode) {
console.error(
`Failed to publish NIP-94 mirror to ${url}`,
mirrorErr
);
}
}
})
);
if (isDevMode) {
console.log(
"NIP-94 mirror dispatched for hosted URL:",
finalUrl
);
}
} catch (mirrorError) {
if (isDevMode) {
console.error(
"Failed to sign/publish NIP-94 mirror event:",
mirrorError
);
}
}
} else if (isDevMode) {
console.log("Skipping NIP-94 mirror: no hosted URL provided.");
}
return signedEvent;
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
@@ -322,14 +736,23 @@ class NostrClient {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
const oldUrl = baseEvent.url || "";
// Determine if the updated note should be private
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
let finalPlainMagnet = (updatedData.magnet || "").trim() || oldPlainMagnet;
let finalMagnet = wantPrivate
? fakeEncrypt(finalPlainMagnet)
: finalPlainMagnet;
const newMagnetValue =
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
let finalMagnet =
wantPrivate && finalPlainMagnet
? fakeEncrypt(finalPlainMagnet)
: finalPlainMagnet;
const newUrlValue =
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
const finalUrl = newUrlValue || oldUrl;
// Use the existing videoRootId (or fall back to the base event's ID)
const oldRootId = baseEvent.videoRootId || baseEvent.id;
@@ -344,6 +767,7 @@ class NostrClient {
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? baseEvent.title,
url: finalUrl,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
@@ -420,6 +844,7 @@ class NostrClient {
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
url: fetched.url,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
@@ -451,6 +876,7 @@ class NostrClient {
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
url: "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
@@ -530,6 +956,7 @@ class NostrClient {
deleted: vid.deleted,
isPrivate: vid.isPrivate,
title: vid.title,
url: vid.url,
magnet: vid.magnet,
thumbnail: vid.thumbnail,
description: vid.description,
@@ -548,12 +975,28 @@ class NostrClient {
* Saves all known events to localStorage (or a different storage if you prefer).
*/
saveLocalData() {
// Convert our allEvents map into a plain object for JSON storage
const allEventsObject = {};
if (typeof localStorage === "undefined") {
return;
}
const payload = {
version: 1,
savedAt: Date.now(),
events: {},
};
for (const [id, vid] of this.allEvents.entries()) {
allEventsObject[id] = vid;
payload.events[id] = vid;
}
try {
localStorage.setItem(EVENTS_CACHE_STORAGE_KEY, JSON.stringify(payload));
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to persist events cache:", err);
}
}
localStorage.setItem("bitvidEvents", JSON.stringify(allEventsObject));
}
/**
@@ -634,7 +1077,7 @@ class NostrClient {
sub.on("eose", () => {
if (isDevMode && invalidDuringSub.length > 0) {
console.warn(
`[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`,
`[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`,
invalidDuringSub
);
}
@@ -646,6 +1089,23 @@ class NostrClient {
});
// Return the subscription object if you need to unsub manually later
const originalUnsub =
typeof sub.unsub === "function" ? sub.unsub.bind(sub) : () => {};
let unsubscribed = false;
sub.unsub = () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
clearInterval(processInterval);
try {
return originalUnsub();
} catch (err) {
console.error("[subscribeVideos] Failed to unsub from pool:", err);
return undefined;
}
};
return sub;
}
@@ -701,7 +1161,7 @@ class NostrClient {
// OPTIONAL: Log invalid stats
if (invalidNotes.length > 0 && isDevMode) {
console.warn(
`Skipped ${invalidNotes.length} invalid v2 notes:\n`,
`Skipped ${invalidNotes.length} invalid video notes:\n`,
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
);
}
+102
View File
@@ -0,0 +1,102 @@
// js/playbackUtils.js
import {
normalizeAndAugmentMagnet,
safeDecodeMagnet,
} from "./magnetUtils.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const MAGNET_URI = /^magnet:\?/i;
/**
* Normalizes torrent related playback inputs into a canonical magnet payload.
*
* The function first trims and safely decodes the incoming `magnet` string so
* that URL encoded magnets become plain text before processing. Bare info hash
* strings are tolerated: whenever the caller supplies an info hash (either via
* the `infoHash` field or a magnet that looks like one) it gets promoted to a
* full magnet URI so downstream WebTorrent code can consume it directly.
*
* When the normalized output differs from the original magnet input (for
* example due to whitespace/encoding fixes or tracker augmentation) the
* original candidate is exposed through `fallbackMagnet`. Callers can surface
* that value if a later refactor breaks magnet normalization.
*
* The returned flags communicate provenance: `provided` indicates that some
* torrent-related input was supplied, while `usedInfoHash` is only `true` when
* the normalized magnet was derived from an info hash instead of an already
* well-formed magnet URI.
*
* Web seeds coming from the `url` parameter are forwarded to
* `normalizeAndAugmentMagnet` via the `webSeed` option. Refactors must preserve
* that flow (even if the parameter name changes) so hosted URLs continue to
* populate `ws=` hints automatically.
*/
export function deriveTorrentPlaybackConfig({
magnet = "",
infoHash = "",
url = "",
logger,
appProtocol,
} = {}) {
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
const magnetCandidate = decodedMagnet || trimmedMagnet;
const trimmedInfoHash =
typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const magnetIsUri = MAGNET_URI.test(magnetCandidate);
const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(magnetCandidate);
const resolvedInfoHash = trimmedInfoHash || (magnetLooksLikeInfoHash
? magnetCandidate.toLowerCase()
: "");
const normalizationInput = magnetIsUri ? magnetCandidate : resolvedInfoHash;
const provided = Boolean(trimmedMagnet || trimmedInfoHash);
const decodeChanged = magnetCandidate !== trimmedMagnet;
if (!normalizationInput) {
return {
magnet: "",
fallbackMagnet: "",
provided,
usedInfoHash: false,
originalInput: "",
didMutate: false,
infoHash: resolvedInfoHash,
};
}
const normalization = normalizeAndAugmentMagnet(normalizationInput, {
webSeed: sanitizedUrl ? [sanitizedUrl] : [],
logger,
appProtocol,
});
let normalizedMagnet = normalization.magnet;
if (!normalizedMagnet || !MAGNET_URI.test(normalizedMagnet)) {
if (magnetIsUri) {
normalizedMagnet = normalizationInput;
} else if (resolvedInfoHash) {
normalizedMagnet = `magnet:?xt=urn:btih:${resolvedInfoHash}`;
} else {
normalizedMagnet = "";
}
}
const usedInfoHash = !magnetIsUri && Boolean(resolvedInfoHash);
const fallbackMagnet = magnetIsUri && normalization.didChange
? normalizationInput
: "";
return {
magnet: normalizedMagnet,
fallbackMagnet,
provided,
usedInfoHash,
originalInput: normalizationInput,
didMutate: normalization.didChange || usedInfoHash || decodeChanged,
infoHash: resolvedInfoHash,
};
}
+160 -43
View File
@@ -1,5 +1,36 @@
// js/subscriptions.js
import { nostrClient } from "./nostr.js";
import {
nostrClient,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
return "";
}
if (window.app?.buildShareUrlFromNevent) {
const candidate = window.app.buildShareUrlFromNevent(nevent);
if (candidate) {
return candidate;
}
}
const origin = window.location?.origin || "";
const pathname = window.location?.pathname || "";
let base = origin || pathname ? `${origin}${pathname}` : "";
if (!base) {
const href = window.location?.href || "";
base = href ? href.split(/[?#]/)[0] : "";
}
if (!base) {
return `?v=${encodeURIComponent(nevent)}`;
}
return `${base}?v=${encodeURIComponent(nevent)}`;
}
/**
* Manages the user's subscription list (kind=30002) *privately*,
@@ -120,6 +151,14 @@ class SubscriptionsManager {
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
const plainStr = JSON.stringify(plainObj);
/*
* The subscription list is stored as a NIP-04 message to self, so both
* encryption and decryption intentionally use the user's own pubkey.
* Extensions are expected to support this encrypt-to-self flow; altering
* the target would break loadSubscriptions, which decrypts with the same
* pubkey. Any future sharing model (e.g., sharing with another user) will
* need a parallel read path and should not overwrite this behavior.
*/
let cipherText = "";
try {
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
@@ -234,7 +273,12 @@ class SubscriptionsManager {
const container = document.getElementById(containerId);
if (!container) return;
if (!videos.length) {
const safeVideos = Array.isArray(videos) ? videos : [];
const dedupedVideos =
window.app?.dedupeVideosByRoot?.(safeVideos) ??
this.dedupeToNewestByRoot(safeVideos);
if (!dedupedVideos.length) {
container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No videos available yet.
@@ -243,25 +287,27 @@ class SubscriptionsManager {
}
// Sort newest first
videos.sort((a, b) => b.created_at - a.created_at);
dedupedVideos.sort((a, b) => b.created_at - a.created_at);
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment();
// Only declare localAuthorSet once
const localAuthorSet = new Set();
videos.forEach((video, index) => {
dedupedVideos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Missing ID or title:", video);
return;
}
// Keep the global videos map up to date so delegated playback handlers
// can reuse the already fetched metadata for this event.
window.app?.videosMap?.set(video.id, video);
localAuthorSet.add(video.pubkey);
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
const shareUrl = getAbsoluteShareUrl(nevent);
const canEdit = window.app?.pubkey === video.pubkey;
const highlightClass =
@@ -283,6 +329,7 @@ class SubscriptionsManager {
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-revert-index="${index}"
data-revert-event-id="${video.id}"
>
Revert
</button>
@@ -311,6 +358,7 @@ class SubscriptionsManager {
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-edit-index="${index}"
data-edit-event-id="${video.id}"
>
Edit
</button>
@@ -318,6 +366,7 @@ class SubscriptionsManager {
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}"
data-delete-all-event-id="${video.id}"
>
Delete All
</button>
@@ -329,11 +378,43 @@ class SubscriptionsManager {
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
const playbackUrl =
typeof video.url === "string" ? video.url : "";
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const playbackMagnet = magnetCandidate;
const magnetProvided = magnetCandidate.length > 0;
const magnetSupported =
window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false;
const urlBadgeHtml = trimmedUrl
? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ??
""
: "";
const torrentHealthBadgeHtml =
magnetProvided && magnetSupported
? window.app?.getTorrentHealthBadgeMarkup?.({
includeMargin: false,
}) ?? ""
: "";
const connectionBadgesHtml =
urlBadgeHtml || torrentHealthBadgeHtml
? `
<div class="mt-3 flex flex-wrap items-center gap-2">
${urlBadgeHtml}${torrentHealthBadgeHtml}
</div>
`
: "";
const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<a
href="${shareUrl}"
data-play-magnet="${encodeURIComponent(video.magnet)}"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
class="block cursor-pointer relative group"
>
<div class="ratio-16-9">
@@ -347,7 +428,9 @@ class SubscriptionsManager {
<div class="p-4">
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-play-magnet="${encodeURIComponent(video.magnet)}"
data-video-id="${video.id}"
data-play-url=""
data-play-magnet=""
>
${safeTitle}
</h3>
@@ -375,6 +458,7 @@ class SubscriptionsManager {
</div>
${gearMenu}
</div>
${connectionBadgesHtml}
</div>
</div>
`;
@@ -382,10 +466,52 @@ class SubscriptionsManager {
const t = document.createElement("template");
t.innerHTML = cardHtml.trim();
const cardEl = t.content.firstElementChild;
if (cardEl) {
// Leave the data-play-* attributes empty in the literal markup so we can
// assign the raw URL/magnet strings post-parsing without HTML entity
// escaping, mirroring the approach in app.js. The URL is encoded so that
// special characters survive storage in data-* attributes; the click
// handler decodes it right before playback while keeping the magnet raw.
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
interactiveEls.forEach((el) => {
if (!el.dataset) return;
if (trimmedUrl) {
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
} else {
delete el.dataset.playUrl;
}
el.dataset.playMagnet = playbackMagnet || "";
});
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
delete cardEl.dataset.magnet;
}
if (trimmedUrl && window.app?.handleUrlHealthBadge) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
window.app.handleUrlHealthBadge({
video,
url: trimmedUrl,
badgeEl,
});
}
}
}
fragment.appendChild(cardEl);
});
container.appendChild(fragment);
attachHealthBadges(container);
if (window.app) {
window.app.videoList = container;
window.app.attachVideoListHandler?.();
}
// Lazy-load
const lazyEls = container.querySelectorAll("[data-lazy]");
@@ -407,10 +533,15 @@ class SubscriptionsManager {
editButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-edit-index");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-edit-index");
const idx = Number.parseInt(idxAttr, 10);
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dropdown) dropdown.classList.add("hidden");
window.app?.handleEditVideo(idx);
const eventId = btn.getAttribute("data-edit-event-id") || "";
window.app?.handleEditVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
@@ -419,10 +550,15 @@ class SubscriptionsManager {
revertButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-revert-index");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-revert-index");
const idx = Number.parseInt(idxAttr, 10);
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dropdown) dropdown.classList.add("hidden");
window.app?.handleRevertVideo(idx);
const eventId = btn.getAttribute("data-revert-event-id") || "";
window.app?.handleRevertVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
@@ -433,10 +569,15 @@ class SubscriptionsManager {
deleteAllButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-delete-all-index");
const dd = document.getElementById(`settingsDropdown-${idx}`);
const idxAttr = btn.getAttribute("data-delete-all-index");
const idx = Number.parseInt(idxAttr, 10);
const dd = document.getElementById(`settingsDropdown-${idxAttr}`);
if (dd) dd.classList.add("hidden");
window.app?.handleFullDeleteVideo(idx);
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
window.app?.handleFullDeleteVideo({
eventId,
index: Number.isNaN(idx) ? null : idx,
});
});
});
@@ -480,31 +621,7 @@ class SubscriptionsManager {
}
convertEventToVideo(evt) {
try {
const content = JSON.parse(evt.content || "{}");
const hasFields = !!(content.title && content.magnet);
const versionOk = content.version >= 2;
if (!versionOk || !hasFields) {
return { id: evt.id, invalid: true };
}
return {
id: evt.id,
pubkey: evt.pubkey,
created_at: evt.created_at,
videoRootId: content.videoRootId || evt.id,
version: content.version,
deleted: content.deleted === true,
isPrivate: content.isPrivate === true,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
tags: evt.tags || [],
invalid: false,
};
} catch (err) {
return { id: evt.id, invalid: true };
}
return sharedConvertEventToVideo(evt);
}
dedupeToNewestByRoot(videos) {
+39
View File
@@ -0,0 +1,39 @@
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
export const TRACKER_TIMEOUT_MS = 3000;
export const TRACKER_PER_MAGNET = 3;
export const HEALTH_TTL_MS = 2 * 60 * 1000;
export const CONCURRENCY = 3;
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
export function resolveTrackerList({ magnetTrackers } = {}) {
const combined = [];
const seen = new Set();
const pushUnique = (url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
if (!/^wss:\/\//i.test(trimmed)) {
return;
}
const normalized = trimmed.toLowerCase();
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
combined.push(trimmed);
};
if (Array.isArray(magnetTrackers)) {
magnetTrackers.forEach(pushUnique);
}
DEFAULT_WSS_TRACKERS.forEach(pushUnique);
return combined.slice(0, TRACKER_PER_MAGNET);
}
+230
View File
@@ -0,0 +1,230 @@
import "./bufferPolyfill.js";
import Client from "https://esm.sh/[email protected]/client?bundle";
import { infoHashFromMagnet, trackersFromMagnet } from "./magnets.js";
import {
resolveTrackerList,
TRACKER_TIMEOUT_MS,
TRACKER_ERROR_COOLDOWN_MS,
} from "./trackerConfig.js";
const trackerState = new Map();
function now() {
return Date.now();
}
function randomPeerId() {
const bytes = new Uint8Array(20);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
return bytes;
}
function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
function getTrackerEntry(url) {
const existing = trackerState.get(url);
if (existing) {
return existing;
}
const entry = {
consecutiveErrors: 0,
lastErrorAt: 0,
cooldownUntil: 0,
};
trackerState.set(url, entry);
return entry;
}
function markTrackerSuccess(url) {
const entry = getTrackerEntry(url);
entry.consecutiveErrors = 0;
entry.cooldownUntil = 0;
}
function markTrackerError(url) {
const entry = getTrackerEntry(url);
const nowTs = now();
if (entry.lastErrorAt && nowTs - entry.lastErrorAt < TRACKER_ERROR_COOLDOWN_MS) {
entry.consecutiveErrors += 1;
} else {
entry.consecutiveErrors = 1;
}
entry.lastErrorAt = nowTs;
if (entry.consecutiveErrors >= 2) {
entry.cooldownUntil = nowTs + TRACKER_ERROR_COOLDOWN_MS;
}
}
function isTrackerUsable(url) {
const entry = getTrackerEntry(url);
return entry.cooldownUntil === 0 || entry.cooldownUntil <= now();
}
export async function trackerPing(magnet, trackers) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
return getDefaultHealth();
}
const magnetTrackers = trackers || trackersFromMagnet(magnet);
const announceList = resolveTrackerList({ magnetTrackers });
const usable = announceList.filter(isTrackerUsable);
const announces = usable.length ? usable : announceList;
if (!announces.length) {
return getDefaultHealth();
}
const peerId = randomPeerId();
const result = getDefaultHealth();
const clients = new Set();
let settled = false;
let timeoutId = null;
const finalize = () => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
clients.forEach((client) => {
try {
client.destroy();
} catch (err) {
// ignore
}
});
clients.clear();
};
return new Promise((resolve) => {
if (Number.isFinite(TRACKER_TIMEOUT_MS) && TRACKER_TIMEOUT_MS > 0) {
timeoutId = setTimeout(() => {
finalize();
resolve(result);
}, TRACKER_TIMEOUT_MS);
}
let remaining = announces.length;
const handleComplete = (client, url) => {
if (clients.has(client)) {
clients.delete(client);
}
if (url && result.from.includes(url) && result.ok) {
markTrackerSuccess(url);
}
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
};
const handleResult = (url, data) => {
result.responded = true;
if (url && !result.from.includes(url)) {
result.from.push(url);
}
const seeders = Number.isFinite(data?.complete)
? Number(data.complete)
: 0;
const leechers = Number.isFinite(data?.incomplete)
? Number(data.incomplete)
: 0;
if (seeders > result.seeders) {
result.seeders = seeders;
}
if (leechers > result.leechers) {
result.leechers = leechers;
}
if (seeders > 0) {
result.ok = true;
}
};
announces.forEach((url) => {
let client;
try {
client = new Client({
infoHash,
peerId,
announce: [url],
});
} catch (err) {
markTrackerError(url);
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
return;
}
clients.add(client);
const cleanupAndResolve = () => {
if (settled) {
return;
}
finalize();
resolve(result);
};
client.once("update", (data) => {
handleResult(url, data);
markTrackerSuccess(url);
if (result.ok) {
cleanupAndResolve();
return;
}
handleComplete(client, url);
});
client.once("error", () => {
markTrackerError(url);
handleComplete(client, url);
});
client.once("warning", () => {
handleComplete(client, url);
});
try {
client.start();
} catch (err) {
markTrackerError(url);
handleComplete(client, url);
}
});
if (announces.length === 0) {
finalize();
resolve(result);
}
});
}
export function getTrackerStateSnapshot() {
const snapshot = {};
trackerState.forEach((value, key) => {
snapshot[key] = { ...value };
});
return snapshot;
}
+41
View File
@@ -0,0 +1,41 @@
// js/tracking.js
// Centralized configuration and loader for the site's analytics script.
// Update `bitvidTrackingConfig` to change the tracking provider or settings.
window.bitvidTrackingConfig = window.bitvidTrackingConfig || {
src: "https://umami.malin.onl/script.js",
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
};
(function loadTrackingScript(config) {
if (!config || !config.src) {
return;
}
if (document.querySelector(`script[src="${config.src}"]`)) {
return;
}
const script = document.createElement("script");
script.src = config.src;
script.defer = true;
if (config.websiteId) {
script.setAttribute("data-website-id", config.websiteId);
}
const additionalAttributes = config.attributes || {};
Object.entries(additionalAttributes).forEach(([key, value]) => {
if (key === "defer") {
script.defer = Boolean(value);
return;
}
if (value === false || value === null || typeof value === "undefined") {
return;
}
script.setAttribute(key, value);
});
document.head.appendChild(script);
})(window.bitvidTrackingConfig);
+292
View File
@@ -0,0 +1,292 @@
// js/videoEventUtils.js
/**
* Extracts normalized fields from a Bitvid video event while
* tolerating legacy payloads that may omit version >= 2 metadata.
*/
const MAGNET_URI_PATTERN = /^magnet:\?/i;
const HEX_INFO_HASH_PATTERN = /\b[0-9a-f]{40}\b/gi;
const LEGACY_MAGNET_PATTERN = /magnet:\?xt=urn:btih:[0-9a-z]{40,}[^\s"'<>]*/i;
function safeTrim(value) {
return typeof value === "string" ? value.trim() : "";
}
function pickFirstString(values = []) {
if (!Array.isArray(values)) {
return "";
}
for (const value of values) {
const trimmed = safeTrim(value);
if (trimmed) {
return trimmed;
}
}
return "";
}
export function deriveTitleFromEvent({
parsedContent = {},
tags = [],
primaryTitle = "",
} = {}) {
const initial = safeTrim(primaryTitle);
if (initial) {
return initial;
}
const directCandidates = pickFirstString([
parsedContent?.title,
parsedContent?.name,
parsedContent?.filename,
parsedContent?.fileName,
parsedContent?.subject,
parsedContent?.caption,
]);
if (directCandidates) {
return directCandidates;
}
const metaTitle = safeTrim(parsedContent?.meta?.title);
if (metaTitle) {
return metaTitle;
}
if (Array.isArray(tags)) {
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const key = safeTrim(tag[0]).toLowerCase();
if (!key) {
continue;
}
if (["title", "subject", "caption", "name"].includes(key)) {
const candidate = safeTrim(tag[1]);
if (candidate) {
return candidate;
}
}
}
}
return "";
}
function extractInfoHashesFromString(value, pushInfoHash) {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
const matches = trimmed.match(HEX_INFO_HASH_PATTERN);
if (!matches) {
return;
}
for (const match of matches) {
const normalized = match.toLowerCase();
pushInfoHash(normalized);
}
}
function traverseForInfoHashes(value, pushInfoHash) {
if (!value) {
return;
}
if (typeof value === "string") {
extractInfoHashesFromString(value, pushInfoHash);
return;
}
if (Array.isArray(value)) {
for (const item of value) {
traverseForInfoHashes(item, pushInfoHash);
}
return;
}
if (typeof value === "object") {
for (const nested of Object.values(value)) {
traverseForInfoHashes(nested, pushInfoHash);
}
}
}
export function parseVideoEventPayload(event = {}) {
const rawContent = typeof event.content === "string" ? event.content : "";
let parsedContent = {};
let parseError = null;
if (rawContent) {
try {
const parsed = JSON.parse(rawContent);
if (parsed && typeof parsed === "object") {
parsedContent = parsed;
}
} catch (err) {
parseError = err;
parsedContent = {};
}
}
const title = typeof parsedContent.title === "string"
? parsedContent.title.trim()
: "";
const thumbnail = typeof parsedContent.thumbnail === "string"
? parsedContent.thumbnail.trim()
: "";
const magnetCandidates = [];
const infoHashCandidates = [];
const urlCandidates = [];
const pushUnique = (arr, value) => {
if (!value || typeof value !== "string") return;
const trimmed = value.trim();
if (!trimmed || arr.includes(trimmed)) return;
arr.push(trimmed);
};
const pushInfoHash = (candidate) => {
if (!candidate) return;
const normalized = candidate.toLowerCase();
if (!/^[0-9a-f]{40}$/.test(normalized)) {
return;
}
if (infoHashCandidates.includes(normalized)) {
return;
}
infoHashCandidates.push(normalized);
};
const collectMagnetOrInfoHash = (value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
if (MAGNET_URI_PATTERN.test(trimmed)) {
pushUnique(magnetCandidates, trimmed);
}
extractInfoHashesFromString(trimmed, pushInfoHash);
const urnMatch = trimmed.match(/^urn:btih:([0-9a-z]+)$/i);
if (urnMatch) {
pushInfoHash(urnMatch[1]);
}
};
if (typeof parsedContent.magnet === "string") {
collectMagnetOrInfoHash(parsedContent.magnet);
}
if (typeof parsedContent.url === "string") {
const parsedUrl = parsedContent.url.trim();
if (parsedUrl && parsedUrl !== thumbnail) {
// Keep this guard so we don't duplicate thumbnail URLs in the playable URL list.
pushUnique(urlCandidates, parsedUrl);
}
// Legacy Bitvid events sometimes embedded magnets/info-hashes in the URL field, so treat it accordingly.
collectMagnetOrInfoHash(parsedUrl);
}
const tags = Array.isArray(event.tags) ? event.tags : [];
const urlTagKeys = new Set(["r", "url", "u"]);
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) continue;
const key = typeof tag[0] === "string" ? tag[0] : "";
const value = typeof tag[1] === "string" ? tag[1] : "";
if (!value) continue;
if (value.toLowerCase().startsWith("magnet:")) {
collectMagnetOrInfoHash(value);
continue;
}
if (urlTagKeys.has(key) && /^https?:\/\//i.test(value)) {
pushUnique(urlCandidates, value);
}
collectMagnetOrInfoHash(value);
}
const magnetMatch = rawContent.match(/magnet:\?xt=urn:[^"'\s<>]+/i);
if (magnetMatch) {
collectMagnetOrInfoHash(magnetMatch[0]);
}
extractInfoHashesFromString(rawContent, pushInfoHash);
traverseForInfoHashes(parsedContent, pushInfoHash);
const magnet = magnetCandidates.find(Boolean) || "";
const infoHash = infoHashCandidates.find(Boolean) || "";
const url =
urlCandidates.find(
(candidate) => candidate && !candidate.toLowerCase().startsWith("magnet:")
) || "";
const rawVersion = parsedContent.version;
let version = 0;
if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) {
version = rawVersion;
} else if (typeof rawVersion === "string") {
const parsedVersion = Number(rawVersion);
if (!Number.isNaN(parsedVersion)) {
version = parsedVersion;
}
}
return {
parsedContent,
parseError,
title,
url,
magnet,
infoHash,
version,
};
}
export function magnetFromText(value) {
if (typeof value !== "string") {
return "";
}
const match = value.match(LEGACY_MAGNET_PATTERN);
return match ? match[0] : "";
}
export function findLegacyMagnetInEvent(event = {}) {
const fromContent = magnetFromText(event?.content);
if (fromContent) {
return fromContent.trim();
}
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const key = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!value) {
continue;
}
if (key === "magnet" && value) {
return value;
}
const fromTag = magnetFromText(value);
if (fromTag) {
return fromTag.trim();
}
}
return "";
}
+9
View File
@@ -2,6 +2,8 @@
import { initChannelProfileView } from "./channelProfile.js";
import { subscriptions } from "./subscriptions.js";
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
/**
* Load a partial view by URL into the #viewContainer.
*/
@@ -23,6 +25,10 @@ export async function loadView(viewUrl) {
// Copy and execute any inline scripts
const scriptTags = doc.querySelectorAll("script");
scriptTags.forEach((oldScript) => {
const src = oldScript.getAttribute("src") || "";
if (src && TRACKING_SCRIPT_PATTERN.test(src)) {
return;
}
const newScript = document.createElement("script");
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
@@ -44,6 +50,9 @@ export const viewInitRegistry = {
"most-recent-videos": () => {
if (window.app && window.app.loadVideos) {
window.app.videoList = document.getElementById("videoList");
if (window.app.attachVideoListHandler) {
window.app.attachVideoListHandler();
}
window.app.loadVideos();
}
// Force profile updates after the new view is in place.
+477 -16
View File
@@ -2,11 +2,145 @@
import WebTorrent from "./webtorrent.min.js";
const DEFAULT_PROBE_TRACKERS = Object.freeze([
"wss://tracker.btorrent.xyz",
"wss://tracker.openwebtorrent.com",
]);
function decodeComponentSafe(value) {
if (typeof value !== "string") {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
function normalizeTrackerList(trackers) {
const normalized = [];
const seen = new Set();
if (!Array.isArray(trackers)) {
return normalized;
}
trackers.forEach((tracker) => {
if (typeof tracker !== "string") {
return;
}
const trimmed = tracker.trim();
if (!trimmed || !/^wss:\/\//i.test(trimmed)) {
return;
}
const lower = trimmed.toLowerCase();
if (seen.has(lower)) {
return;
}
seen.add(lower);
normalized.push(trimmed);
});
return normalized;
}
function appendProbeTrackers(magnetURI, trackers) {
if (typeof magnetURI !== "string") {
return { magnet: "", appended: false, hasProbeTrackers: false };
}
const trimmedMagnet = magnetURI.trim();
if (!trimmedMagnet) {
return { magnet: "", appended: false, hasProbeTrackers: false };
}
const probeTrackers = normalizeTrackerList(trackers);
if (!probeTrackers.length) {
return {
magnet: trimmedMagnet,
appended: false,
hasProbeTrackers: false,
};
}
const trackerSet = new Set();
const [withoutFragment, fragment = ""] = trimmedMagnet.split("#", 2);
const [, queryPart = ""] = withoutFragment.split("?", 2);
if (queryPart) {
queryPart
.split("&")
.map((segment) => segment.trim())
.filter(Boolean)
.forEach((segment) => {
const [rawKey, rawValue = ""] = segment.split("=", 2);
if (!rawKey || rawKey.trim().toLowerCase() !== "tr") {
return;
}
const decoded = decodeComponentSafe(rawValue).trim().toLowerCase();
if (decoded) {
trackerSet.add(decoded);
}
});
}
const normalizedProbe = probeTrackers.map((url) => url.toLowerCase());
const hadProbeTracker = normalizedProbe.some((url) => trackerSet.has(url));
const toAppend = [];
probeTrackers.forEach((tracker, index) => {
const normalizedTracker = normalizedProbe[index];
if (trackerSet.has(normalizedTracker)) {
return;
}
trackerSet.add(normalizedTracker);
toAppend.push(`tr=${encodeURIComponent(tracker)}`);
});
if (!toAppend.length) {
return {
magnet: trimmedMagnet,
appended: false,
hasProbeTrackers: hadProbeTracker,
};
}
const separator = queryPart ? "&" : "?";
const augmented = `${withoutFragment}${separator}${toAppend.join("&")}`;
const finalMagnet = fragment
? `${augmented}#${fragment}`
: augmented;
return {
magnet: finalMagnet,
appended: true,
hasProbeTrackers: true,
};
}
function normalizeNumber(value, fallback = 0) {
const coerced = Number(value);
if (Number.isFinite(coerced)) {
return coerced;
}
return fallback;
}
function toError(err) {
if (err instanceof Error) {
return err;
}
try {
return new Error(String(err));
} catch (stringifyError) {
return new Error("Unknown error");
}
}
export class TorrentClient {
constructor() {
// Reusable objects and flags
this.client = null;
this.currentTorrent = null;
this.probeClient = null;
// Service worker registration is cached
this.swRegistration = null;
@@ -16,6 +150,155 @@ export class TorrentClient {
this.TIMEOUT_DURATION = 60000;
}
ensureClientForProbe() {
if (!this.probeClient) {
this.probeClient = new WebTorrent();
}
return this.probeClient;
}
async probePeers(
magnetURI,
{ timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {}
) {
const magnet = typeof magnetURI === "string" ? magnetURI.trim() : "";
if (!magnet) {
return {
healthy: false,
peers: 0,
reason: "invalid",
appendedTrackers: false,
hasProbeTrackers: false,
usedTrackers: [...TorrentClient.PROBE_TRACKERS],
durationMs: 0,
};
}
const trackers = normalizeTrackerList(TorrentClient.PROBE_TRACKERS);
const { magnet: augmentedMagnet, appended, hasProbeTrackers } =
appendProbeTrackers(magnet, trackers);
if (!hasProbeTrackers) {
return {
healthy: false,
peers: 0,
reason: "no-trackers",
appendedTrackers: false,
hasProbeTrackers: false,
usedTrackers: trackers,
durationMs: 0,
};
}
const client = this.ensureClientForProbe();
const safeTimeout = Math.max(0, normalizeNumber(timeoutMs, 8000));
const safePolls = Math.max(1, Math.floor(normalizeNumber(polls, 3)));
const safeMaxWebConns = Math.max(1, Math.floor(normalizeNumber(maxWebConns, 2)));
const pollInterval = Math.max(
250,
Math.floor(safeTimeout / Math.max(1, safePolls))
);
const startedAt =
typeof performance !== "undefined" && performance?.now
? performance.now()
: Date.now();
return new Promise((resolve) => {
let settled = false;
let torrent = null;
let timeoutId = null;
let pollId = null;
const finalize = (overrides = {}) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (pollId) {
clearInterval(pollId);
pollId = null;
}
if (torrent) {
try {
torrent.destroy({ destroyStore: true });
} catch (err) {
// ignore
}
}
const endedAt =
typeof performance !== "undefined" && performance?.now
? performance.now()
: Date.now();
resolve({
healthy: false,
peers: 0,
reason: "timeout",
appendedTrackers: appended,
hasProbeTrackers,
usedTrackers: trackers,
durationMs: Math.max(0, endedAt - startedAt),
...overrides,
});
};
try {
torrent = client.add(augmentedMagnet, {
announce: trackers,
maxWebConns: safeMaxWebConns,
});
} catch (err) {
finalize({
reason: "error",
error: toError(err),
peers: 0,
});
return;
}
const settleHealthy = () => {
const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1)));
finalize({ healthy: true, peers, reason: "peer" });
};
torrent.once("wire", settleHealthy);
torrent.once("error", (err) => {
finalize({
healthy: false,
reason: "error",
error: toError(err),
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
});
});
if (safeTimeout > 0) {
timeoutId = setTimeout(() => {
finalize({
healthy: false,
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
});
}, safeTimeout);
}
pollId = setInterval(() => {
if (!torrent || settled) {
return;
}
const peers = Math.max(0, Math.floor(normalizeNumber(torrent.numPeers, 0)));
if (peers > 0) {
finalize({ healthy: true, peers, reason: "peer" });
}
}, pollInterval);
});
}
log(msg) {
console.log(msg);
}
@@ -43,6 +326,13 @@ export class TorrentClient {
// 2) If we haven’t registered the service worker yet, do it now
if (!this.swRegistration) {
this.swRegistration = await this.setupServiceWorker();
} else {
// Even with an existing registration we still wait for control so that a
// transient controller drop (e.g. Chrome devtools unregister/reload) does
// not resurrect the grey-screen regression mentioned in
// waitForActiveController().
this.requestClientsClaim(this.swRegistration);
await this.waitForActiveController(this.swRegistration);
}
}
@@ -81,6 +371,117 @@ export class TorrentClient {
});
}
/**
* Ensure a live service worker is actively controlling the page before we
* start WebTorrent streaming.
*
* This regression has bitten us repeatedly: the first playback attempt after
* a cold load would get stuck on a grey frame because `navigator.serviceWorker`
* had finished installing but never claimed the page yet. WebTorrent's
* service-worker proxy never attached, so `file.streamTo(videoElement)` fed
* data into the void. Refreshing the page “fixed” it because the worker became
* the controller during navigation. Future refactors **must not** remove this
* guard — wait for `controllerchange` whenever `controller` is still null.
*/
requestClientsClaim(registration = this.swRegistration) {
if (!("serviceWorker" in navigator)) {
return;
}
try {
const activeWorker = registration?.active;
if (!activeWorker) {
return;
}
// Some Chromium builds require an explicit startMessages() call before a
// yet-to-claim worker will accept postMessage traffic. Calling it is a
// harmless no-op elsewhere.
if (navigator.serviceWorker.startMessages) {
navigator.serviceWorker.startMessages();
}
activeWorker.postMessage({ type: "ENSURE_CLIENTS_CLAIM" });
} catch (err) {
this.log("Failed to request clients.claim():", err);
}
}
async waitForActiveController(registration = this.swRegistration) {
if (!("serviceWorker" in navigator)) {
return null;
}
if (navigator.serviceWorker.controller) {
return navigator.serviceWorker.controller;
}
this.requestClientsClaim(registration);
return new Promise((resolve, reject) => {
let timeoutId = null;
let pollId = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (pollId) {
clearInterval(pollId);
pollId = null;
}
navigator.serviceWorker.removeEventListener(
"controllerchange",
onControllerChange
);
};
const maybeResolve = () => {
const controller = navigator.serviceWorker.controller;
if (controller) {
cleanup();
resolve(controller);
return true;
}
return false;
};
const onControllerChange = () => {
if (maybeResolve()) {
return;
}
// If we received a controllerchange event but still don't have a
// controller it usually means the new worker hasn't claimed this page
// yet. Ask it again to be safe.
this.requestClientsClaim(registration);
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Service worker controller claim timeout"));
}, this.TIMEOUT_DURATION);
pollId = setInterval(() => {
if (maybeResolve()) {
return;
}
this.requestClientsClaim(registration);
}, 500);
navigator.serviceWorker.addEventListener(
"controllerchange",
onControllerChange
);
// One last check in case the controller appeared between the earlier
// synchronous guard and the promise wiring above.
if (!maybeResolve()) {
this.requestClientsClaim(registration);
}
});
}
async setupServiceWorker() {
try {
const isBraveBrowser = await this.isBrave();
@@ -92,7 +493,15 @@ export class TorrentClient {
throw new Error("Service Worker not supported or disabled");
}
// Brave-specific logic
// Brave-specific logic: Brave Shields has a long-standing bug where
// stale service worker registrations linger even after we ship fixes.
// When that happens, the outdated worker keeps intercepting requests
// and WebTorrent fails to spin up, leaving playback broken until the
// user manually nukes the registration. To guarantee a clean slate we
// blanket-unregister every worker, then pause briefly so Brave can
// finish tearing down the old instance before we register the fresh
// one again. That delay is intentional—future tweaks should not shorten
// or remove it without understanding the regression risk.
if (isBraveBrowser) {
this.log("Checking Brave configuration...");
if (!navigator.serviceWorker) {
@@ -160,6 +569,16 @@ export class TorrentClient {
throw new Error("Service worker not active after ready state");
}
// Give the newly activated worker an explicit nudge to claim the page
// before we continue. This keeps Chromium's occasionally sluggish claim
// hand-offs from derailing the subsequent wait below.
this.requestClientsClaim(registration);
// See waitForActiveController() docstring for why this must remain in
// place. We intentionally wait here instead of racing with playback so a
// newly installed worker claims the page before WebTorrent spins up.
await this.waitForActiveController(registration);
// Force the SW to check for updates
registration.update();
this.log("Service worker ready");
@@ -171,8 +590,30 @@ export class TorrentClient {
}
}
attemptAutoplay(videoElement, context = "webtorrent") {
if (!videoElement || typeof videoElement.play !== "function") {
return;
}
videoElement
.play()
.catch((err) => {
this.log(`Autoplay failed (${context} path):`, err);
if (videoElement.muted) {
return;
}
this.log(`Retrying with muted autoplay (${context} path).`);
videoElement.muted = true;
videoElement.play().catch((err2) => {
this.log(`Muted autoplay also failed (${context} path):`, err2);
});
});
}
// Handle Chrome-based browsers
handleChromeTorrent(torrent, videoElement, resolve, reject) {
// Prune demo web seeds/trackers that chronically trip Chromium CORS and
// deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
torrent.on("warning", (err) => {
if (err && typeof err.message === "string") {
if (
@@ -203,18 +644,20 @@ export class TorrentClient {
return reject(new Error("No compatible video file found in torrent"));
}
videoElement.muted = true;
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error:", e.target.error);
});
videoElement.addEventListener("canplay", () => {
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
videoElement.addEventListener(
"canplay",
() => {
this.attemptAutoplay(videoElement, "chrome");
},
{ once: true }
);
try {
file.streamTo(videoElement);
@@ -240,18 +683,20 @@ export class TorrentClient {
return reject(new Error("No compatible video file found in torrent"));
}
videoElement.muted = true;
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error (Firefox path):", e.target.error);
});
videoElement.addEventListener("canplay", () => {
videoElement.play().catch((err) => {
this.log("Autoplay failed:", err);
});
});
videoElement.addEventListener(
"canplay",
() => {
this.attemptAutoplay(videoElement, "firefox");
},
{ once: true }
);
try {
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
@@ -272,7 +717,7 @@ export class TorrentClient {
* Initiates streaming of a torrent magnet to a <video> element.
* Ensures the service worker is set up only once and the client is reused.
*/
async streamVideo(magnetURI, videoElement) {
async streamVideo(magnetURI, videoElement, opts = {}) {
try {
// 1) Make sure we have a WebTorrent client and a valid SW registration.
await this.init();
@@ -288,6 +733,16 @@ export class TorrentClient {
}
const isFirefoxBrowser = this.isFirefox();
const candidateUrls = Array.isArray(opts?.urlList)
? opts.urlList
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter((entry) => /^https?:\/\//i.test(entry))
: [];
const chromeOptions = { strategy: "sequential" };
if (candidateUrls.length) {
chromeOptions.urlList = candidateUrls;
}
return new Promise((resolve, reject) => {
// 3) Add the torrent to the client and handle accordingly.
@@ -295,7 +750,7 @@ export class TorrentClient {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
{ strategy: "sequential", maxWebConns: 4 },
{ ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
@@ -303,7 +758,7 @@ export class TorrentClient {
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, { strategy: "sequential" }, (torrent) => {
this.client.add(magnetURI, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
});
@@ -330,6 +785,10 @@ export class TorrentClient {
await this.client.destroy();
this.client = null;
}
if (this.probeClient) {
await this.probeClient.destroy();
this.probeClient = null;
}
this.currentTorrent = null;
this.serverCreated = false;
} catch (error) {
@@ -338,4 +797,6 @@ export class TorrentClient {
}
}
TorrentClient.PROBE_TRACKERS = DEFAULT_PROBE_TRACKERS;
export const torrentClient = new TorrentClient();
Vendored
+3
View File
@@ -15,6 +15,9 @@
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
);
}
if (event.data && event.data.type === "ENSURE_CLIENTS_CLAIM") {
event.waitUntil(clients.claim());
}
});
// Immediately install and skip waiting
+136
View File
@@ -0,0 +1,136 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { parseVideoEventPayload } from "../js/videoEventUtils.js";
import { convertEventToVideo } from "../js/nostr.js";
import { deriveTorrentPlaybackConfig } from "../js/playbackUtils.js";
const LEGACY_INFO_HASH = "0123456789abcdef0123456789abcdef01234567";
(function testParseDetectsBareInfoHashInJson() {
const event = {
id: "evt-json",
content: JSON.stringify({
version: 1,
title: "Legacy note",
magnet: LEGACY_INFO_HASH,
}),
tags: [],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.magnet, "", "Bare info hash should not be treated as a magnet URI");
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testParseDetectsInfoHashInRawContentAndTags() {
const event = {
id: "evt-tag",
content: JSON.stringify({ title: "tag-sourced" }),
tags: [["magnet", LEGACY_INFO_HASH]],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testParseDetectsInfoHashInRawString() {
const event = {
id: "evt-raw",
content: `legacy magnet ${LEGACY_INFO_HASH} broken json`,
tags: [],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testConvertFlagsBareInfoHashAsInvalid() {
const event = {
id: "evt-convert",
pubkey: "pk",
created_at: 1,
tags: [],
content: JSON.stringify({
version: 1,
title: "Legacy conversion",
magnet: LEGACY_INFO_HASH,
}),
};
const video = convertEventToVideo(event);
assert.equal(video.invalid, true, "Bare info-hash payloads should be flagged");
assert.equal(video.reason, "missing playable source");
})();
(function testLegacyEventWithoutTitleStillLoads() {
const event = {
id: "evt-no-title",
pubkey: "pk2",
created_at: 2,
tags: [],
content: JSON.stringify({
version: 1,
magnet: `magnet:?xt=urn:btih:${LEGACY_INFO_HASH}`,
}),
};
const video = convertEventToVideo(event);
assert.equal(video.invalid, false, "Legacy events without title should fallback");
assert.ok(video.title && video.title.length > 0, "Fallback title should be provided");
})();
(function testConvertRecoversMagnetFromMagnetTag() {
const magnetUri =
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=legacy-tag&tr=wss://tracker.openwebtorrent.com`;
const event = {
id: "evt-magnet-tag",
pubkey: "pk3",
created_at: 3,
// Deliberately invalid JSON so the converter must rely on scavenging.
content: "{not valid json}",
tags: [["magnet", magnetUri]],
};
const video = convertEventToVideo(event);
assert.equal(video.invalid, false, "Magnet in tag should still be playable");
assert.equal(video.magnet, magnetUri);
assert.equal(video.rawMagnet, magnetUri);
})();
(function testPlaybackConfigNormalizesInfoHash() {
const result = deriveTorrentPlaybackConfig({
magnet: "",
infoHash: LEGACY_INFO_HASH,
url: "",
});
assert.ok(result.magnet.startsWith("magnet:?"));
const xtValues = new URL(result.magnet).searchParams.getAll("xt");
assert.deepEqual(xtValues, [`urn:btih:${LEGACY_INFO_HASH}`]);
assert.equal(result.usedInfoHash, true);
assert.equal(result.fallbackMagnet, "");
assert.equal(result.provided, true);
})();
(function testPlaybackConfigDecodesEncodedMagnet() {
const rawMagnet =
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=Legacy+Example`;
const encodedMagnet = encodeURIComponent(rawMagnet);
const result = deriveTorrentPlaybackConfig({
magnet: encodedMagnet,
infoHash: "",
url: "",
});
assert.ok(result.magnet.startsWith("magnet:?"));
const parsed = new URL(result.magnet);
assert.equal(
parsed.searchParams.get("xt"),
`urn:btih:${LEGACY_INFO_HASH}`,
"Expected encoded magnet to be normalized"
);
assert.equal(result.provided, true);
assert.equal(result.usedInfoHash, false);
})();
console.log("legacy infohash tests passed");
+160
View File
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import { WSS_TRACKERS } from "../js/constants.js";
import { normalizeAndAugmentMagnet, safeDecodeMagnet } from "../js/magnetUtils.js";
import { normalizeAndAugmentMagnet as formNormalize } from "../js/magnet.js";
function getParamValues(magnet, key) {
const parsed = new URL(magnet);
return parsed.searchParams.getAll(key);
}
(function testBareInfoHashNormalization() {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const result = normalizeAndAugmentMagnet(infoHash);
assert.ok(result.didChange, "Bare info hash should mark didChange true");
const xtValues = getParamValues(result.magnet, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
const trackerValues = getParamValues(result.magnet, "tr");
for (const tracker of WSS_TRACKERS) {
assert.ok(
trackerValues.includes(tracker),
`Expected tracker ${tracker} to be appended`
);
}
})();
(function testEncodedXtDecoding() {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`;
const result = normalizeAndAugmentMagnet(encodedMagnet);
const xtValues = getParamValues(result.magnet, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
})();
(function testDuplicateTrackerFiltering() {
const infoHash = "fedcba9876543210fedcba9876543210fedcba98";
const baseMagnet =
`magnet:?xt=urn:btih:${infoHash}` +
"&tr=wss://tracker.openwebtorrent.com" +
"&tr=WsS://tracker.openwebtorrent.com/" +
"&tr=https://not-allowed.example";
const result = normalizeAndAugmentMagnet(baseMagnet, {
extraTrackers: [
"wss://tracker.fastcast.nz",
"http://tracker.invalid",
"wss://tracker.openwebtorrent.com",
],
});
const trackers = getParamValues(result.magnet, "tr");
const openwebCount = trackers.filter(
(value) => value === "wss://tracker.openwebtorrent.com"
).length;
assert.equal(openwebCount, 1, "Duplicate WSS tracker should be collapsed");
assert.ok(
trackers.includes("wss://tracker.fastcast.nz"),
"Expected extra WSS tracker to be preserved"
);
assert.ok(
!trackers.includes("http://tracker.invalid"),
"Non-WSS tracker candidates should be ignored"
);
})();
(function testWebSeedHandlingWithLogging() {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const messages = [];
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: [
"https://cdn.example.com/video.mp4",
"http://cdn.example.com/legacy.mp4",
"https://cdn.example.com/video.mp4",
],
logger: (msg) => messages.push(msg),
appProtocol: "https:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]);
assert.equal(messages.length, 1, "HTTP seed should have been skipped with a log");
assert.ok(
messages[0].includes("Skipping insecure web seed"),
"Expected skip message for HTTP seed"
);
})();
(function testHttpWebSeedAllowedOnHttpOrigin() {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: "http://cdn.example.com/video.mp4",
appProtocol: "http:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]);
})();
(function testSafeDecodeMagnetHandlesEncodedValues() {
const rawMagnet =
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example";
const encodedMagnet = encodeURIComponent(rawMagnet);
assert.equal(
safeDecodeMagnet(encodedMagnet),
rawMagnet,
"Expected percent-encoded magnet to be decoded"
);
})();
(function testSafeDecodeMagnetLeavesPlainStringsUntouched() {
const rawMagnet =
"magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd";
assert.equal(
safeDecodeMagnet(rawMagnet),
rawMagnet,
"Plain magnet strings should pass through unchanged"
);
})();
(function testFormHelperAugmentsWsXsAndDecodesXt() {
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}`;
const normalized = formNormalize(encodedMagnet, {
ws: "https://cdn.example.com/video-base/",
xs: "https://cdn.example.com/video.torrent",
});
const parsed = new URL(normalized);
assert.equal(
parsed.searchParams.get("xt"),
`urn:btih:${infoHash}`,
"Form helper should decode encoded xt payloads"
);
assert.deepEqual(
parsed.searchParams.getAll("ws"),
["https://cdn.example.com/video-base/"],
"Expected ws hint to be appended"
);
assert.deepEqual(
parsed.searchParams.getAll("xs"),
["https://cdn.example.com/video.torrent"],
"Expected xs hint to be appended"
);
const trackers = parsed.searchParams.getAll("tr");
for (const tracker of WSS_TRACKERS) {
assert.ok(
trackers.includes(tracker),
`Expected helper to append tracker ${tracker}`
);
}
})();
(function testFormHelperSkipsInsecureWsOnHttpsOrigin() {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const normalized = formNormalize(`magnet:?xt=urn:btih:${infoHash}`, {
ws: "http://cdn.example.com/video-base/",
});
const parsed = new URL(normalized);
assert.equal(
parsed.searchParams.getAll("ws").length,
0,
"HTTP ws value should be skipped on HTTPS origins"
);
})();
console.log("magnet-utils tests passed");
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<title>Nostr Ephemeral DM Tester (Using finalizeEvent)</title>
<script src="../js/tracking.js" defer></script>
<style>
/* Basic styling for the demo page */
body {
+17
View File
@@ -0,0 +1,17 @@
if (typeof globalThis.localStorage === "undefined") {
const store = new Map();
globalThis.localStorage = {
getItem(key) {
return store.has(key) ? store.get(key) : null;
},
setItem(key, value) {
store.set(String(key), String(value));
},
removeItem(key) {
store.delete(key);
},
clear() {
store.clear();
},
};
}
+1
View File
@@ -17,6 +17,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Scripts: webtorrent, angular, etc. -->
<script src="../js/tracking.js" defer></script>
<script src="bundle.min.js"></script>
<!--<script src="https://cdn.jsdelivr.net/combine/npm/webtorrent/webtorrent.min.js,npm/moment@2,npm/[email protected]/angular.min.js,npm/angular-[email protected]/angular-route.min.js,npm/angular-[email protected]/angular-sanitize.min.js,npm/angular-ui-grid@3/ui-grid.min.js,gh/matowens/ng-notify/dist/ng-notify.min.js,npm/ng-file-[email protected]/dist/ng-file-upload.min.js"></script>-->
+15 -1
View File
@@ -43,7 +43,21 @@
<!-- Area for Subscribe/Unsubscribe button -->
<div class="px-4 my-4">
<div id="subscribeBtnArea" class="hidden"></div>
<div class="flex items-center gap-3">
<button
id="zapButton"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<div id="subscribeBtnArea" class="hidden"></div>
</div>
</div>
<hr class="my-6" />