Skip to content

Commit f18eb76

Browse files
authored
Add video urls to text posts (#14080)
1 parent d66e089 commit f18eb76

20 files changed

Lines changed: 923 additions & 158 deletions

File tree

packages/common/src/api/tan-query/comments/usePostTextUpdate.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type PostTextUpdateArgs = {
1414
body: string
1515
mint: string
1616
isMembersOnly?: boolean
17+
videoUrl?: string
1718
}
1819

1920
export const usePostTextUpdate = () => {
@@ -31,12 +32,13 @@ export const usePostTextUpdate = () => {
3132
entityType: 'FanClub',
3233
body: args.body,
3334
mentions: [],
34-
isMembersOnly: args.isMembersOnly ?? true
35+
isMembersOnly: args.isMembersOnly ?? true,
36+
videoUrl: args.videoUrl
3537
} as any
3638
})
3739
},
3840
onMutate: async (args: PostTextUpdateArgs & { newId?: ID }) => {
39-
const { userId, body, entityId, mint, isMembersOnly } = args
41+
const { userId, body, entityId, mint, isMembersOnly, videoUrl } = args
4042
const sdk = await audiusSdk()
4143
const newId = await sdk.comments.generateCommentId()
4244
args.newId = newId
@@ -55,7 +57,8 @@ export const usePostTextUpdate = () => {
5557
replies: undefined,
5658
createdAt: new Date().toISOString(),
5759
updatedAt: undefined,
58-
isMembersOnly: isMembersOnly ?? true
60+
isMembersOnly: isMembersOnly ?? true,
61+
videoUrl
5962
}
6063

6164
// Prime the individual comment cache

packages/common/src/api/tan-query/utils/primeCommentData.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export const primeCommentData = ({
1717
}) => {
1818
// Populate individual comment cache
1919
comments.forEach((comment) => {
20-
// Prime the main comment
2120
queryClient.setQueryData(getCommentQueryKey(comment.id), comment)
2221

2322
// Prime any replies if they exist

packages/common/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@ export * from './quickSearch'
5454
export * from './coinMetrics'
5555
export * from './convertHexToRGBA'
5656
export * from './socialLinks'
57+
export * from './videoUtils'
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export type VideoPlatform = 'youtube' | 'vimeo'
2+
3+
export type ParsedVideo = {
4+
platform: VideoPlatform
5+
videoId: string
6+
}
7+
8+
const YOUTUBE_REGEX =
9+
/(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
10+
11+
const VIMEO_REGEX = /(?:vimeo\.com\/)(\d+)/
12+
13+
/**
14+
* Parse a video URL and extract the platform and video ID.
15+
* Supports YouTube and Vimeo URLs.
16+
*/
17+
export const parseVideoUrl = (url: string): ParsedVideo | null => {
18+
const youtubeMatch = url.match(YOUTUBE_REGEX)
19+
if (youtubeMatch) {
20+
return { platform: 'youtube', videoId: youtubeMatch[1] }
21+
}
22+
23+
const vimeoMatch = url.match(VIMEO_REGEX)
24+
if (vimeoMatch) {
25+
return { platform: 'vimeo', videoId: vimeoMatch[1] }
26+
}
27+
28+
return null
29+
}
30+
31+
/**
32+
* Get the thumbnail URL for a video. Only YouTube provides static thumbnail URLs.
33+
* Vimeo requires an API call, so returns null.
34+
*/
35+
export const getVideoThumbnailUrl = (
36+
parsed: ParsedVideo
37+
): string | null => {
38+
if (parsed.platform === 'youtube') {
39+
return `https://img.youtube.com/vi/${parsed.videoId}/hqdefault.jpg`
40+
}
41+
return null
42+
}
43+
44+
/**
45+
* Get the embeddable URL for a video.
46+
*/
47+
export const getVideoEmbedUrl = (parsed: ParsedVideo): string => {
48+
if (parsed.platform === 'youtube') {
49+
return `https://www.youtube.com/embed/${parsed.videoId}`
50+
}
51+
return `https://player.vimeo.com/video/${parsed.videoId}`
52+
}
53+
54+
/**
55+
* Get the watch URL for a video (for opening in a new tab).
56+
*/
57+
export const getVideoWatchUrl = (parsed: ParsedVideo): string => {
58+
if (parsed.platform === 'youtube') {
59+
return `https://www.youtube.com/watch?v=${parsed.videoId}`
60+
}
61+
return `https://vimeo.com/${parsed.videoId}`
62+
}
63+
64+
/**
65+
* Check if a URL is a valid YouTube or Vimeo video URL.
66+
*/
67+
export const isValidVideoUrl = (url: string): boolean => {
68+
return parseVideoUrl(url) !== null
69+
}

packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py

Lines changed: 63 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import List
1010

1111
from sqlalchemy import text
12+
from web3 import Web3
1213
from web3.datastructures import AttributeDict
1314

1415
from integration_tests.challenges.index_helpers import UpdateTask
@@ -22,8 +23,6 @@
2223
from src.tasks.entity_manager.entity_manager import entity_manager_update
2324
from src.utils.db_session import get_db
2425

25-
from web3 import Web3
26-
2726
logger = logging.getLogger(__name__)
2827

2928
# Base entities shared across fan club post (text post) tests.
@@ -158,9 +157,9 @@ def test_create_fan_club_post(app, mocker):
158157
assert comments[0].entity_id == 1
159158
assert comments[0].user_id == 1
160159

161-
notifications = session.query(Notification).filter(
162-
Notification.type == "comment"
163-
).all()
160+
notifications = (
161+
session.query(Notification).filter(Notification.type == "comment").all()
162+
)
164163
assert len(notifications) == 0
165164

166165

@@ -169,12 +168,14 @@ def test_create_fan_club_post_invalid_no_artist_coin(app, mocker):
169168
Creating a fan club post for a user_id that has no artist coin should be
170169
silently skipped (validation error).
171170
"""
172-
bad_metadata = json.dumps({
173-
"entity_id": 99, # user 99 doesn't exist and has no coin
174-
"entity_type": "FanClub",
175-
"body": "should fail",
176-
"parent_comment_id": None,
177-
})
171+
bad_metadata = json.dumps(
172+
{
173+
"entity_id": 99, # user 99 doesn't exist and has no coin
174+
"entity_type": "FanClub",
175+
"body": "should fail",
176+
"parent_comment_id": None,
177+
}
178+
)
178179

179180
tx_receipts = {
180181
"BadFanClubPost": [
@@ -223,11 +224,13 @@ def test_fan_club_post_reply(app, mocker):
223224
],
224225
}
225226

226-
reply_metadata = json.dumps({
227-
**fan_club_post_metadata,
228-
"body": "replying to your post",
229-
"parent_comment_id": 1,
230-
})
227+
reply_metadata = json.dumps(
228+
{
229+
**fan_club_post_metadata,
230+
"body": "replying to your post",
231+
"parent_comment_id": 1,
232+
}
233+
)
231234

232235
tx_receipts = {
233236
"FanClubPostReply": [
@@ -283,12 +286,14 @@ def test_fan_club_post_reply_non_owner_rejected(app, mocker):
283286
],
284287
}
285288

286-
cross_thread_metadata = json.dumps({
287-
"entity_id": 1,
288-
"entity_type": "FanClub",
289-
"body": "cross-thread reply",
290-
"parent_comment_id": 1, # parent is a Track comment, not fan club
291-
})
289+
cross_thread_metadata = json.dumps(
290+
{
291+
"entity_id": 1,
292+
"entity_type": "FanClub",
293+
"body": "cross-thread reply",
294+
"parent_comment_id": 1, # parent is a Track comment, not fan club
295+
}
296+
)
292297

293298
tx_receipts = {
294299
"CrossThreadReply": [
@@ -537,11 +542,13 @@ def test_update_fan_club_post(app, mocker):
537542
],
538543
}
539544

540-
update_metadata = json.dumps({
541-
"entity_id": 1,
542-
"entity_type": "FanClub",
543-
"body": "edited text",
544-
})
545+
update_metadata = json.dumps(
546+
{
547+
"entity_id": 1,
548+
"entity_type": "FanClub",
549+
"body": "edited text",
550+
}
551+
)
545552

546553
tx_receipts = {
547554
"EditFanClubPost": [
@@ -578,11 +585,13 @@ def test_update_fan_club_post(app, mocker):
578585

579586
def test_fan_club_post_with_mention(app, mocker):
580587
"""Mentions in a fan club text post generate mention notifications."""
581-
mention_metadata = json.dumps({
582-
**fan_club_post_metadata,
583-
"body": "hey @user3 check this out",
584-
"mentions": [3],
585-
})
588+
mention_metadata = json.dumps(
589+
{
590+
**fan_club_post_metadata,
591+
"body": "hey @user3 check this out",
592+
"mentions": [3],
593+
}
594+
)
586595

587596
tx_receipts = {
588597
"FanClubPostMention": [
@@ -629,12 +638,14 @@ def test_fan_club_post_and_track_comments_coexist(app, mocker):
629638
Creating both a fan club post and a Track comment in the same block
630639
produces the correct entity_type on each and independent notifications.
631640
"""
632-
track_comment_metadata = json.dumps({
633-
"entity_id": 1,
634-
"entity_type": "Track",
635-
"body": "great track!",
636-
"parent_comment_id": None,
637-
})
641+
track_comment_metadata = json.dumps(
642+
{
643+
"entity_id": 1,
644+
"entity_type": "Track",
645+
"body": "great track!",
646+
"parent_comment_id": None,
647+
}
648+
)
638649

639650
tx_receipts = {
640651
"FanClubPost": [
@@ -684,9 +695,9 @@ def test_fan_club_post_and_track_comments_coexist(app, mocker):
684695
assert track_comment.entity_id == 1
685696

686697
# Fan club owner self-post does not notify; track comment notifies track owner.
687-
notifs = session.query(Notification).filter(
688-
Notification.type == "comment"
689-
).all()
698+
notifs = (
699+
session.query(Notification).filter(Notification.type == "comment").all()
700+
)
690701
assert len(notifs) == 1
691702
assert notifs[0].group_id == "comment:1:type:Track"
692703

@@ -712,7 +723,9 @@ def _seed_coin_holders(session, artist_user_id=1, holder_user_ids=None, mint=COI
712723
session.flush()
713724

714725

715-
def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders(app, mocker):
726+
def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders(
727+
app, mocker
728+
):
716729
"""
717730
When an artist creates a root-level fan club text post, both followers
718731
and coin holders receive a fan_club_text_post notification (deduplicated).
@@ -767,9 +780,7 @@ def test_fan_club_text_post_notification_sent_to_followers_and_coin_holders(app,
767780

768781
# No "comment" notification since artist is posting on their own fan club
769782
comment_notifs = (
770-
session.query(Notification)
771-
.filter(Notification.type == "comment")
772-
.all()
783+
session.query(Notification).filter(Notification.type == "comment").all()
773784
)
774785
assert len(comment_notifs) == 0
775786

@@ -835,11 +846,13 @@ def test_fan_club_text_post_notification_not_sent_for_replies(app, mocker):
835846
],
836847
}
837848

838-
reply_metadata = json.dumps({
839-
**fan_club_post_metadata,
840-
"body": "replying to my own post",
841-
"parent_comment_id": 1,
842-
})
849+
reply_metadata = json.dumps(
850+
{
851+
**fan_club_post_metadata,
852+
"body": "replying to my own post",
853+
"parent_comment_id": 1,
854+
}
855+
)
843856

844857
tx_receipts = {
845858
"ArtistReply": [

packages/discovery-provider/src/api/v1/models/comments.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"is_artist_reacted": fields.Boolean(required=False),
2828
"created_at": fields.String(required=True),
2929
"updated_at": fields.String(required=False),
30+
"video_url": fields.String(required=False),
3031
},
3132
)
3233

@@ -55,6 +56,7 @@
5556
"created_at": fields.String(required=True),
5657
"updated_at": fields.String(required=False),
5758
"replies": fields.List(fields.Nested(reply_comment_model), require=True),
59+
"video_url": fields.String(required=False),
5860
},
5961
)
6062

packages/discovery-provider/src/models/comments/comment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Comment(Base, RepresentableMixin):
2121
is_visible = Column(Boolean, default=True)
2222
is_edited = Column(Boolean, default=False)
2323
is_members_only = Column(Boolean, default=False, nullable=False)
24+
video_url = Column(Text, nullable=True)
2425
txhash = Column(Text, nullable=False)
2526
blockhash = Column(Text, nullable=False)
2627
blocknumber = Column(Integer, ForeignKey("blocks.number"), nullable=False)

packages/discovery-provider/src/queries/comments/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from sqlalchemy import func
44

5+
from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE
56
from src.models.comments.comment_mention import CommentMention
67
from src.models.comments.comment_notification_setting import CommentNotificationSetting
78
from src.models.comments.comment_reaction import CommentReaction
89
from src.models.comments.comment_report import COMMENT_KARMA_THRESHOLD
910
from src.models.moderation.muted_user import MutedUser
10-
from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE
1111
from src.models.users.aggregate_user import AggregateUser
1212
from src.models.users.user import User
1313
from src.queries.query_helpers import get_tracks, get_users
@@ -145,6 +145,7 @@ def is_reacted(user_id, comment_id):
145145
"created_at": str(comment.created_at),
146146
"updated_at": str(comment.updated_at),
147147
"is_muted": is_muted if is_muted is not None else False,
148+
"video_url": getattr(comment, "video_url", None),
148149
}
149150

150151
# Check if we need to include replies (either explicitly provided or need to fetch them)
@@ -878,6 +879,7 @@ def get_comment_replies(
878879
"created_at": str(reply.created_at),
879880
"updated_at": str(reply.updated_at),
880881
"is_muted": False, # Replies don't have mute status
882+
"video_url": getattr(reply, "video_url", None),
881883
"is_artist_reacted": (
882884
reactions_map.get((artist_id, reply.comment_id), False)
883885
if reactions_map

0 commit comments

Comments
 (0)