diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php
index f7d51da..f7efd36 100644
--- a/src/Controller/AuthorController.php
+++ b/src/Controller/AuthorController.php
@@ -23,9 +23,6 @@ class AuthorController extends AbstractController
#[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])]
public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService): Response
{
- $keys = new Key();
- $pubkey = $keys->convertToHex($npub);
-
$author = $redisCacheService->getMetadata($npub);
// Retrieve picture events (kind 20) for the author
@@ -35,22 +32,39 @@ class AuthorController extends AbstractController
$pictureEvents = [];
}
+ // Retrieve video shorts (kind 22) for the author
+ try {
+ $videoShorts = $nostrClient->getVideoShortsForPubkey($npub, 30);
+ } catch (Exception $e) {
+ $videoShorts = [];
+ }
+
+ // Retrieve normal videos (kind 21) for the author
+ try {
+ $normalVideos = $nostrClient->getNormalVideosForPubkey($npub, 30);
+ } catch (Exception $e) {
+ $normalVideos = [];
+ }
+
+ // Merge picture events, video shorts, and normal videos
+ $mediaEvents = array_merge($pictureEvents, $videoShorts, $normalVideos);
+
// Deduplicate by event ID
$uniqueEvents = [];
- foreach ($pictureEvents as $event) {
+ foreach ($mediaEvents as $event) {
if (!isset($uniqueEvents[$event->id])) {
$uniqueEvents[$event->id] = $event;
}
}
// Convert back to indexed array and sort by date (newest first)
- $pictureEvents = array_values($uniqueEvents);
- usort($pictureEvents, function ($a, $b) {
+ $mediaEvents = array_values($uniqueEvents);
+ usort($mediaEvents, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
// Encode event IDs as note1... for each event
- foreach ($pictureEvents as $event) {
+ foreach ($mediaEvents as $event) {
$nip19 = new Nip19Helper(); // The NIP-19 helper class.
$event->noteId = $nip19->encodeNote($event->id);
}
@@ -58,7 +72,7 @@ class AuthorController extends AbstractController
return $this->render('pages/author-media.html.twig', [
'author' => $author,
'npub' => $npub,
- 'pictureEvents' => $pictureEvents,
+ 'pictureEvents' => $mediaEvents,
'is_author_profile' => true,
]);
}
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 155fd64..ee07782 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -546,6 +546,66 @@ class NostrClient
});
}
+ /**
+ * Get video shorts (kind 22) for a given pubkey
+ * @throws \Exception
+ */
+ public function getVideoShortsForPubkey(string $ident, int $limit = 20): array
+ {
+ // Add user relays to the default set
+ $authorRelays = $this->getTopReputableRelaysForAuthor($ident);
+ // Create a RelaySet from the author's relays
+ $relaySet = $this->defaultRelaySet;
+ if (!empty($authorRelays)) {
+ $relaySet = $this->createRelaySet($authorRelays);
+ }
+
+ // Create request for kind 22 (short video events)
+ $request = $this->createNostrRequest(
+ kinds: [22], // NIP-71 Short video events
+ filters: [
+ 'authors' => [$ident],
+ 'limit' => $limit
+ ],
+ relaySet: $relaySet
+ );
+
+ // Process the response and return raw events
+ return $this->processResponse($request->send(), function($event) {
+ return $event; // Return the raw event
+ });
+ }
+
+ /**
+ * Get normal video events (kind 21) for a given pubkey
+ * @throws \Exception
+ */
+ public function getNormalVideosForPubkey(string $ident, int $limit = 20): array
+ {
+ // Add user relays to the default set
+ $authorRelays = $this->getTopReputableRelaysForAuthor($ident);
+ // Create a RelaySet from the author's relays
+ $relaySet = $this->defaultRelaySet;
+ if (!empty($authorRelays)) {
+ $relaySet = $this->createRelaySet($authorRelays);
+ }
+
+ // Create request for kind 21 (normal video events)
+ $request = $this->createNostrRequest(
+ kinds: [21], // NIP-71 Normal video events
+ filters: [
+ 'authors' => [$ident],
+ 'limit' => $limit
+ ],
+ relaySet: $relaySet
+ );
+
+ // Process the response and return raw events
+ return $this->processResponse($request->send(), function($event) {
+ return $event; // Return the raw event
+ });
+ }
+
public function getArticles(array $slugs): array
{
$articles = [];
diff --git a/templates/event/_kind22_video.html.twig b/templates/event/_kind22_video.html.twig
new file mode 100644
index 0000000..1465aa2
--- /dev/null
+++ b/templates/event/_kind22_video.html.twig
@@ -0,0 +1,162 @@
+{# NIP-71 Video Event (kind 22) #}
+
+ {# Title tag #}
+ {% set title = null %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'title' %}
+ {% set title = tag[1] %}
+ {% endif %}
+ {% endfor %}
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
+ {# Content warning #}
+ {% set contentWarning = null %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'content-warning' %}
+ {% set contentWarning = tag[1] %}
+ {% endif %}
+ {% endfor %}
+
+ {% if contentWarning %}
+
+ ⚠️ Content Warning: {{ contentWarning }}
+
+
+
+ {% else %}
+
+ {% endif %}
+
+ {# Display videos from imeta tags #}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'imeta' %}
+ {% set videoUrl = null %}
+ {% set mimeType = null %}
+ {% set dimensions = null %}
+ {% set altText = null %}
+ {% set previewImage = null %}
+ {% set fallbacks = [] %}
+
+ {# Parse imeta tag parameters #}
+ {% for i in 1..(tag|length - 1) %}
+ {% set param = tag[i] %}
+ {% if param starts with 'url ' %}
+ {% set potentialUrl = param[4:] %}
+ {# Check if it's a video URL #}
+ {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %}
+ {% set videoUrl = potentialUrl %}
+ {% endif %}
+ {% elseif param starts with 'image ' %}
+ {% set previewImage = param[6:] %}
+ {% elseif param starts with 'm ' %}
+ {% set mimeType = param[2:] %}
+ {% elseif param starts with 'dim ' %}
+ {% set dimensions = param[4:] %}
+ {% elseif param starts with 'alt ' %}
+ {% set altText = param[4:] %}
+ {% elseif param starts with 'fallback ' %}
+ {% set fallbackUrl = param[9:] %}
+ {# Only add video fallbacks #}
+ {% if fallbackUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or fallbackUrl matches '/video/i' %}
+ {% set fallbacks = fallbacks|merge([fallbackUrl]) %}
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+
+ {% if videoUrl %}
+
+
+
+ {% if altText %}
+
{{ altText }}
+ {% endif %}
+
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+
+
+ {# Description from content #}
+ {% if event.content %}
+
+
+
+ {% endif %}
+
+ {# Duration #}
+ {% set duration = null %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'duration' %}
+ {% set duration = tag[1] %}
+ {% endif %}
+ {% endfor %}
+
+ {% if duration %}
+
+ {% endif %}
+
+ {# Published timestamp #}
+ {% set publishedAt = null %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'published_at' %}
+ {% set publishedAt = tag[1] %}
+ {% endif %}
+ {% endfor %}
+
+ {% if publishedAt %}
+
+ Originally published: {{ publishedAt|date('F j, Y') }}
+
+ {% endif %}
+
+ {# Hashtags #}
+ {% set hashtags = [] %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 't' %}
+ {% set hashtags = hashtags|merge([tag[1]]) %}
+ {% endif %}
+ {% endfor %}
+
+ {% if hashtags|length > 0 %}
+
+ {% for hashtag in hashtags %}
+ #{{ hashtag }}
+ {% endfor %}
+
+ {% endif %}
+
+ {# Participants #}
+ {% set participants = [] %}
+ {% for tag in event.tags %}
+ {% if tag[0] == 'p' %}
+ {% set participants = participants|merge([tag[1]]) %}
+ {% endif %}
+ {% endfor %}
+
+ {% if participants|length > 0 %}
+
+
Participants:
+
+ {% for pubkey in participants %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
diff --git a/templates/event/index.html.twig b/templates/event/index.html.twig
index 61e1455..8532645 100644
--- a/templates/event/index.html.twig
+++ b/templates/event/index.html.twig
@@ -22,8 +22,11 @@
{# NIP-68 Picture Event (kind 20) #}
{% if event.kind == 20 %}
{% include 'event/_kind20_picture.html.twig' %}
+ {# NIP-71 Video Events (kind 21 and 22) #}
+ {% elseif event.kind == 21 or event.kind == 22 %}
+ {% include 'event/_kind22_video.html.twig' %}
{% else %}
- {# Regular event content for non-picture events #}
+ {# Regular event content for non-picture and non-video events #}
@@ -47,7 +50,7 @@
{# Source link from r tag #}
{% set sourceUrl = null %}
{% for tag in event.tags %}
- {% if tag[0] == 'r' and tag[2] == 'source' %}
+ {% if tag[0] == 'r' and tag|length > 2 and tag[2] == 'source' %}
{% set sourceUrl = tag[1] %}
{% endif %}
{% endfor %}
diff --git a/templates/pages/author-media.html.twig b/templates/pages/author-media.html.twig
index c6b7e2e..1503186 100644
--- a/templates/pages/author-media.html.twig
+++ b/templates/pages/author-media.html.twig
@@ -50,17 +50,38 @@
{# Extract first image from imeta tags #}
{% set firstImageUrl = null %}
+ {% set firstVideoUrl = null %}
{% set imageAlt = null %}
+ {% set isVideo = false %}
{% for tag in event.tags %}
- {% if tag[0] == 'imeta' and firstImageUrl is null %}
+ {% if tag[0] == 'imeta' %}
+ {% set videoUrl = null %}
+ {% set imageUrl = null %}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
- {% if param starts with 'url ' and firstImageUrl is null %}
- {% set firstImageUrl = param[4:] %}
+ {% if param starts with 'url ' %}
+ {% set potentialUrl = param[4:] %}
+ {# Check if it's a video URL #}
+ {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %}
+ {% set videoUrl = potentialUrl %}
+ {% set isVideo = true %}
+ {% else %}
+ {% set imageUrl = potentialUrl %}
+ {% endif %}
+ {% elseif param starts with 'image ' %}
+ {# Preview image for video #}
+ {% set imageUrl = param[6:] %}
{% elseif param starts with 'alt ' %}
{% set imageAlt = param[4:] %}
{% endif %}
{% endfor %}
+ {# Set the first video and image found #}
+ {% if videoUrl and firstVideoUrl is null %}
+ {% set firstVideoUrl = videoUrl %}
+ {% endif %}
+ {% if imageUrl and firstImageUrl is null %}
+ {% set firstImageUrl = imageUrl %}
+ {% endif %}
{% endif %}
{% endfor %}
@@ -72,9 +93,16 @@
{% if firstImageUrl %}
+ {% if isVideo %}
+
+
+
+ {% endif %}
{% endif %}
@@ -104,3 +132,31 @@
{% endif %}
{% endblock %}
+
+{% block stylesheets %}
+{{ parent() }}
+
+{% endblock %}
diff --git a/tests/NIPs/NIP-71.feature b/tests/NIPs/NIP-71.feature
new file mode 100644
index 0000000..ffbdd34
--- /dev/null
+++ b/tests/NIPs/NIP-71.feature
@@ -0,0 +1,157 @@
+Feature: NIP-71 Video Events
+ As a nostr application developer
+ I want to create video content events
+ So that videos can contribute to the multi-media experience
+
+ Background:
+ Given I have a valid nostr keypair
+ And I am connected to a relay
+
+ Scenario: Creating a basic normal video event
+ Given I create a video event with kind 21
+ And I set a title tag with "My First Video"
+ And I add a description in the content
+ And I include an imeta tag with video URL "https://myvideo.com/1080/12345.mp4"
+ And I set the video mime type to "video/mp4"
+ And I set the video dimensions to "1920x1080"
+ And I include a preview image "https://myvideo.com/1080/12345.jpg"
+ When I publish the video event
+ Then the event should be stored and retrievable
+ And the video should be self-contained with external hosting
+
+ Scenario: Creating a short-form video event
+ Given I create a video event with kind 22
+ And I set a title tag with "My Short Story"
+ And I add a description in the content
+ And I include an imeta tag with video URL "https://myvideo.com/shorts/67890.mp4"
+ And I set the video mime type to "video/mp4"
+ And I set the video dimensions to "1080x1920"
+ When I publish the video event
+ Then the event should be stored as a short-form video
+ And clients should display it in a vertical format
+
+ Scenario: Video event with multiple quality variants
+ Given I create a video event with kind 21
+ And I set a title tag
+ And I include an imeta tag for 1920x1080 resolution with URL "https://myvideo.com/1080/12345.mp4"
+ And I include an imeta tag for 1280x720 resolution with URL "https://myvideo.com/720/12345.mp4"
+ And I include an imeta tag for 1280x720 HLS stream with URL "https://myvideo.com/720/12345.m3u8"
+ And each variant has a SHA-256 hash using "x" parameter
+ When I publish the video event
+ Then all video variants should be available
+ And each variant should have its own imeta tag with dimensions
+
+ Scenario: Video with complete metadata
+ Given I create a video event with kind 21
+ And I include an imeta tag with URL, mime type, dimensions, and SHA-256 hash
+ And I add a preview image for the video
+ And I add an alt text for accessibility
+ And I provide fallback URLs
+ And I include "service nip96" in the imeta tag
+ And I set a duration tag with "300" seconds
+ And I set a published_at timestamp
+ When I publish the video event
+ Then the video metadata should be complete and queryable
+
+ Scenario: Video with fallback servers
+ Given I create a video event with kind 21
+ And I set the primary video URL to "https://myvideo.com/1080/12345.mp4"
+ And I add fallback URLs "https://myotherserver.com/1080/12345.mp4"
+ And I add fallback URLs "https://andanotherserver.com/1080/12345.mp4"
+ And I add preview image fallbacks
+ When I publish the video event
+ Then clients should be able to use any of the provided URLs equally
+
+ Scenario: Tagging participants in videos
+ Given I create a video event with kind 21
+ And I include p tags for multiple participants
+ And I add recommended relay URLs for each participant
+ When I publish the video event
+ Then tagged participants should be linked to the video
+ And users should be notified of being tagged
+
+ Scenario: NSFW content warning for video
+ Given I create a video event with sensitive content
+ And I add a content-warning tag with reason
+ When I publish the video event
+ Then clients should display a content warning before showing the video
+
+ Scenario: Video with hashtags
+ Given I create a video event with kind 21
+ And I add multiple t tags for hashtags
+ When I publish the video event
+ Then the video should be discoverable by hashtags
+
+ Scenario: Video with text tracks (captions/subtitles)
+ Given I create a video event with kind 21
+ And I add a text-track tag linking to WebVTT file "https://myvideo.com/captions/en.vtt"
+ And I specify the track type as "captions"
+ And I specify the language code as "en"
+ When I publish the video event
+ Then the video should support captions and subtitles
+
+ Scenario: Video with chapter segments
+ Given I create a video event with kind 21
+ And I add a segment tag with start "00:00:00.000", end "00:05:30.000", title "Introduction"
+ And I add a segment tag with start "00:05:30.000", end "00:15:45.000", title "Main Content"
+ And I include thumbnail URLs for each segment
+ When I publish the video event
+ Then the video should have navigable chapters
+
+ Scenario: Video with reference links
+ Given I create a video event with kind 21
+ And I add multiple r tags with reference URLs
+ When I publish the video event
+ Then the reference links should be associated with the video
+
+ Scenario: Supported video formats
+ Given I create a video event with kind 21
+ When I try to use a video with mime type ""
+ Then the event should accept valid video types
+ Examples:
+ | mime_type |
+ | video/mp4 |
+ | video/webm |
+ | video/ogg |
+ | video/quicktime |
+ | application/x-mpegURL |
+
+ Scenario: Video with NIP-96 service integration
+ Given I create a video event with kind 21
+ And I include "service nip96" in the imeta tag
+ And I include SHA-256 hash for the video
+ When I publish the video event
+ Then clients should be able to search the author's NIP-96 server list
+ And the file should be findable using the hash
+
+ Scenario: Queryable video hashes
+ Given I create a video event with multiple variants
+ And I include x tags with SHA-256 hashes for each variant
+ When I publish the video event
+ Then the videos should be queryable by their hashes
+
+ Scenario: Video with published timestamp
+ Given I create a video event with kind 21
+ And I set a published_at tag with the first publication timestamp
+ When I publish the video event
+ Then the original publication time should be preserved
+ And it should differ from the created_at timestamp if republished
+
+ Scenario: Complete video event structure
+ Given I create a video event with kind 21
+ And I set a title tag with "Complete Tutorial Video"
+ And I add a summary in the content field
+ And I set a published_at timestamp
+ And I add an alt text description
+ And I include imeta tags for multiple quality variants
+ And I set a duration tag
+ And I add text-track tags for captions
+ And I add a content-warning if needed
+ And I add segment tags for chapters
+ And I include p tags for participants
+ And I add t tags for hashtags
+ And I add r tags for reference links
+ When I publish the video event
+ Then all metadata should be properly structured
+ And the event should be fully compatible with video-specific clients
+