From 9fec009f0dd291611a7d4c60756edabb583b7bb0 Mon Sep 17 00:00:00 2001 From: Tom Gobich Date: Sat, 28 Dec 2024 15:24:13 -0500 Subject: [PATCH] feat: adding R2 video source, chapters, & captions in preparation for Cloudflare R2 testing --- app/actions/posts/destroy_post.ts | 3 + app/actions/posts/get_post.ts | 3 + app/actions/posts/store_post.ts | 7 +- app/actions/posts/sync_captions.ts | 36 ++++ app/actions/posts/sync_chapters.ts | 36 ++++ app/actions/posts/update_post.ts | 7 +- app/dtos/post.ts | 6 + app/dtos/post_caption.ts | 34 ++++ app/dtos/post_chapter.ts | 34 ++++ app/dtos/post_form.ts | 36 ++++ app/enums/caption_languages.ts | 17 ++ app/enums/caption_types.ts | 11 ++ app/models/post.ts | 8 + app/models/post_caption.ts | 49 ++++++ app/models/post_chapter.ts | 51 ++++++ app/validators/post.ts | 26 ++- ...735411574023_create_post_captions_table.ts | 23 +++ ...735411585674_create_post_chapters_table.ts | 22 +++ inertia/components/VideoPreview.vue | 26 ++- inertia/pages/posts/form.vue | 155 +++++++++++++++++- 20 files changed, 582 insertions(+), 8 deletions(-) create mode 100644 app/actions/posts/sync_captions.ts create mode 100644 app/actions/posts/sync_chapters.ts create mode 100644 app/dtos/post_caption.ts create mode 100644 app/dtos/post_chapter.ts create mode 100644 app/enums/caption_languages.ts create mode 100644 app/enums/caption_types.ts create mode 100644 app/models/post_caption.ts create mode 100644 app/models/post_chapter.ts create mode 100644 database/migrations/1735411574023_create_post_captions_table.ts create mode 100644 database/migrations/1735411585674_create_post_chapters_table.ts diff --git a/app/actions/posts/destroy_post.ts b/app/actions/posts/destroy_post.ts index 6ca62fd..70aff04 100644 --- a/app/actions/posts/destroy_post.ts +++ b/app/actions/posts/destroy_post.ts @@ -24,6 +24,9 @@ export default class DestroyPost { await post.related('collections').detach() await post.related('taxonomies').detach() + await post.related('chapters').query().delete() + await post.related('captions').query().delete() + await this.#destroyComments(post, trx) await this.#destroyHistory(post) await this.#removeFromWatchlists(post) diff --git a/app/actions/posts/get_post.ts b/app/actions/posts/get_post.ts index 845d23d..317addc 100644 --- a/app/actions/posts/get_post.ts +++ b/app/actions/posts/get_post.ts @@ -6,7 +6,10 @@ export default class GetPost { .where({ id }) .preload('thumbnails', (query) => query.orderBy('sort_order')) .preload('covers', (query) => query.orderBy('sort_order')) + .preload('chapters', (query) => query.orderBy('sort_order')) + .preload('captions', (query) => query.orderBy('sort_order')) .preload('taxonomies') + .firstOrFail() } } diff --git a/app/actions/posts/store_post.ts b/app/actions/posts/store_post.ts index 8015e73..613dc4a 100644 --- a/app/actions/posts/store_post.ts +++ b/app/actions/posts/store_post.ts @@ -7,6 +7,8 @@ import db from '@adonisjs/lucid/services/db' import { Infer } from '@vinejs/vine/types' import SyncPostAsset from './sync_post_assets.js' import SyncTaxonomies from '#actions/taxonomies/sync_taxonomies' +import SyncCaptions from './sync_captions.js' +import SyncChapters from './sync_chapters.js' type Data = Infer @@ -20,7 +22,8 @@ export default class StorePost { if (!data.stateId) data.stateId = States.DRAFT if (!data.postTypeId) data.postTypeId = PostTypes.LESSON - const { thumbnail, publishAtDate, publishAtTime, taxonomyIds, ...store } = data + const { thumbnail, publishAtDate, publishAtTime, taxonomyIds, captions, chapters, ...store } = + data return db.transaction(async (trx) => { const post = await Post.create(store, { client: trx }) @@ -29,6 +32,8 @@ export default class StorePost { await SyncTaxonomies.handle({ resource: post, ids: taxonomyIds }) await SyncPostAsset.handle({ post, asset: thumbnail }, trx) + await SyncCaptions.handle({ post, captions }, trx) + await SyncChapters.handle({ post, chapters }, trx) return post }) diff --git a/app/actions/posts/sync_captions.ts b/app/actions/posts/sync_captions.ts new file mode 100644 index 0000000..17ad62f --- /dev/null +++ b/app/actions/posts/sync_captions.ts @@ -0,0 +1,36 @@ +import Post from '#models/post' +import { postValidator } from '#validators/post' +import { TransactionClientContract } from '@adonisjs/lucid/types/database' +import { Infer } from '@vinejs/vine/types' + +type Params = { + post: Post + captions: Infer['captions'] +} + +export default class SyncCaptions { + static async handle({ post, captions }: Params, trx: TransactionClientContract) { + if (!captions?.length) { + await post.related('captions').query().delete() + return + } + + const promises = captions.map(async (caption, index) => { + if (!caption.id) { + return post.related('captions').create(caption, { client: trx }) + } + + const row = await post.related('captions').query().where('id', caption.id).firstOrFail() + + row.useTransaction(trx) + row.merge({ + sortOrder: index, + ...caption, + }) + + return row.save() + }) + + await Promise.all(promises) + } +} diff --git a/app/actions/posts/sync_chapters.ts b/app/actions/posts/sync_chapters.ts new file mode 100644 index 0000000..34c02b5 --- /dev/null +++ b/app/actions/posts/sync_chapters.ts @@ -0,0 +1,36 @@ +import Post from '#models/post' +import { postValidator } from '#validators/post' +import { TransactionClientContract } from '@adonisjs/lucid/types/database' +import { Infer } from '@vinejs/vine/types' + +type Params = { + post: Post + chapters: Infer['chapters'] +} + +export default class SyncChapters { + static async handle({ post, chapters }: Params, trx: TransactionClientContract) { + if (!chapters?.length) { + await post.related('chapters').query().delete() + return + } + + const promises = chapters.map(async (chapter, index) => { + if (!chapter.id) { + return post.related('chapters').create(chapter, { client: trx }) + } + + const row = await post.related('chapters').query().where('id', chapter.id).firstOrFail() + + row.useTransaction(trx) + row.merge({ + sortOrder: index, + ...chapter, + }) + + return row.save() + }) + + await Promise.all(promises) + } +} diff --git a/app/actions/posts/update_post.ts b/app/actions/posts/update_post.ts index 5588128..bb2e6e0 100644 --- a/app/actions/posts/update_post.ts +++ b/app/actions/posts/update_post.ts @@ -6,6 +6,8 @@ import db from '@adonisjs/lucid/services/db' import { Infer } from '@vinejs/vine/types' import SyncPostAsset from './sync_post_assets.js' import SyncTaxonomies from '#actions/taxonomies/sync_taxonomies' +import SyncCaptions from './sync_captions.js' +import SyncChapters from './sync_chapters.js' type Data = Infer @@ -25,7 +27,8 @@ export default class UpdatePost { if (!data.stateId) data.stateId = States.DRAFT if (!data.postTypeId) data.postTypeId = PostTypes.LESSON - const { thumbnail, publishAtDate, publishAtTime, taxonomyIds, ...update } = data + const { thumbnail, publishAtDate, publishAtTime, taxonomyIds, captions, chapters, ...update } = + data post.merge(update) @@ -39,6 +42,8 @@ export default class UpdatePost { await post.save() await SyncTaxonomies.handle({ resource: post, ids: taxonomyIds }) await SyncPostAsset.handle({ post, asset: thumbnail }, trx) + await SyncCaptions.handle({ post, captions }, trx) + await SyncChapters.handle({ post, chapters }, trx) return post }) diff --git a/app/dtos/post.ts b/app/dtos/post.ts index d0e9d11..b5d2dad 100644 --- a/app/dtos/post.ts +++ b/app/dtos/post.ts @@ -13,6 +13,8 @@ import VideoTypes from '#enums/video_types' import States from '#enums/states' import PaywallTypes from '#enums/paywall_types' import PostTypes from '#enums/post_types' +import PostChapterDto from './post_chapter.js' +import PostCaptionDto from './post_caption.js' export default class PostDto extends BaseModelDto { declare id: number @@ -69,6 +71,8 @@ export default class PostDto extends BaseModelDto { declare viewHistory: HistoryDto[] declare progressionHistory: ProgressDto[] declare watchlist: WatchlistDto[] + declare chapters: PostChapterDto[] + declare captions: PostCaptionDto[] declare publishAtISO: string | null declare publishAtDisplay: string @@ -146,6 +150,8 @@ export default class PostDto extends BaseModelDto { this.viewHistory = HistoryDto.fromArray(post.viewHistory) this.progressionHistory = ProgressDto.fromArray(post.progressionHistory) this.watchlist = WatchlistDto.fromArray(post.watchlist) + this.chapters = PostChapterDto.fromArray(post.chapters) + this.captions = PostCaptionDto.fromArray(post.captions) this.publishAtISO = post.publishAtISO this.publishAtDisplay = post.publishAtDisplay diff --git a/app/dtos/post_caption.ts b/app/dtos/post_caption.ts new file mode 100644 index 0000000..d2414a7 --- /dev/null +++ b/app/dtos/post_caption.ts @@ -0,0 +1,34 @@ +import { BaseModelDto } from '@adocasts.com/dto/base' +import PostCaption from '#models/post_caption' +import CaptionTypes from '#enums/caption_types' +import CaptionLanguages from '#enums/caption_languages' +import PostDto from '#dtos/post' + +export default class PostCaptionDto extends BaseModelDto { + declare id: number + declare postId: number + declare type: CaptionTypes + declare label: string + declare language: CaptionLanguages + declare filename: string + declare sortOrder: number + declare createdAt: string + declare updatedAt: string + declare post: PostDto | null + + constructor(postCaption?: PostCaption) { + super() + + if (!postCaption) return + this.id = postCaption.id + this.postId = postCaption.postId + this.type = postCaption.type + this.label = postCaption.label + this.language = postCaption.language + this.filename = postCaption.filename + this.sortOrder = postCaption.sortOrder + this.createdAt = postCaption.createdAt.toISO()! + this.updatedAt = postCaption.updatedAt.toISO()! + this.post = postCaption.post && new PostDto(postCaption.post) + } +} diff --git a/app/dtos/post_chapter.ts b/app/dtos/post_chapter.ts new file mode 100644 index 0000000..2b954fb --- /dev/null +++ b/app/dtos/post_chapter.ts @@ -0,0 +1,34 @@ +import { BaseModelDto } from '@adocasts.com/dto/base' +import PostChapter from '#models/post_chapter' +import PostDto from '#dtos/post' + +export default class PostChapterDto extends BaseModelDto { + declare id: number + declare postId: number + declare start: string + declare end: string + declare text: string + declare sortOrder: number + declare createdAt: string + declare updatedAt: string + declare post: PostDto | null + declare startSeconds: number + declare endSeconds: number + + constructor(postChapter?: PostChapter) { + super() + + if (!postChapter) return + this.id = postChapter.id + this.postId = postChapter.postId + this.start = postChapter.start + this.end = postChapter.end + this.text = postChapter.text + this.sortOrder = postChapter.sortOrder + this.createdAt = postChapter.createdAt.toISO()! + this.updatedAt = postChapter.updatedAt.toISO()! + this.post = postChapter.post && new PostDto(postChapter.post) + this.startSeconds = postChapter.startSeconds + this.endSeconds = postChapter.endSeconds + } +} diff --git a/app/dtos/post_form.ts b/app/dtos/post_form.ts index 1105edf..5418f04 100644 --- a/app/dtos/post_form.ts +++ b/app/dtos/post_form.ts @@ -5,6 +5,22 @@ import VideoTypes from '#enums/video_types' import States from '#enums/states' import PaywallTypes from '#enums/paywall_types' import PostTypes from '#enums/post_types' +import CaptionTypes from '#enums/caption_types' +import CaptionLanguages from '#enums/caption_languages' + +type Caption = { + id?: number + type: CaptionTypes + language: CaptionLanguages + label: string +} + +type Chapter = { + id?: number + start: string + end: string + text: string +} export default class PostFormDto extends BaseModelDto { declare id: number @@ -38,6 +54,8 @@ export default class PostFormDto extends BaseModelDto { declare thumbnail: AssetDto | null declare cover: AssetDto | null declare taxonomyIds: number[] + declare captions: Caption[] | null + declare chapters: Chapter[] | null constructor(post?: Post) { super() @@ -74,5 +92,23 @@ export default class PostFormDto extends BaseModelDto { this.thumbnail = post.thumbnails.length ? new AssetDto(post.thumbnails[0]) : null this.cover = post.covers.length ? new AssetDto(post.covers[0]) : null this.taxonomyIds = post.taxonomies?.map((row) => row.id) ?? [] + + if (post.captions?.length) { + this.captions = post.captions.map((row) => ({ + id: row.id, + type: row.type, + language: row.language, + label: row.label, + })) + } + + if (post.chapters?.length) { + this.chapters = post.chapters.map((row) => ({ + id: row.id, + start: row.start, + end: row.end, + text: row.text, + })) + } } } diff --git a/app/enums/caption_languages.ts b/app/enums/caption_languages.ts new file mode 100644 index 0000000..0df0ee5 --- /dev/null +++ b/app/enums/caption_languages.ts @@ -0,0 +1,17 @@ +enum CaptionLanguages { + ENGLISH = 'en', + SPANISH = 'es', + FRENCH = 'fr', + GERMAN = 'de', + PORTUGUESE = 'pt', +} + +export const CaptionLanguageDesc: Record = { + [CaptionLanguages.ENGLISH]: 'English', + [CaptionLanguages.SPANISH]: 'Spanish', + [CaptionLanguages.FRENCH]: 'French', + [CaptionLanguages.GERMAN]: 'German', + [CaptionLanguages.PORTUGUESE]: 'Portuguese', +} + +export default CaptionLanguages diff --git a/app/enums/caption_types.ts b/app/enums/caption_types.ts new file mode 100644 index 0000000..411e1fc --- /dev/null +++ b/app/enums/caption_types.ts @@ -0,0 +1,11 @@ +enum CaptionTypes { + SRT = 'srt', + VTT = 'vtt', +} + +export const CaptionTypeDesc: Record = { + [CaptionTypes.SRT]: 'SRT', + [CaptionTypes.VTT]: 'VTT', +} + +export default CaptionTypes diff --git a/app/models/post.ts b/app/models/post.ts index 3e5503f..b7fd0ee 100644 --- a/app/models/post.ts +++ b/app/models/post.ts @@ -27,6 +27,8 @@ import SlugService from '#services/slug_service' import router from '@adonisjs/core/services/router' import Progress from './progress.js' import PostTypes from '#enums/post_types' +import PostCaption from './post_caption.js' +import PostChapter from './post_chapter.js' export default class Post extends AppBaseModel { serializeExtras = true @@ -164,6 +166,12 @@ export default class Post extends AppBaseModel { @hasMany(() => Comment) declare comments: HasMany + @hasMany(() => PostCaption) + declare captions: HasMany + + @hasMany(() => PostChapter) + declare chapters: HasMany + @manyToMany(() => User, { pivotTable: 'author_posts', pivotColumns: ['author_type_id'], diff --git a/app/models/post_caption.ts b/app/models/post_caption.ts new file mode 100644 index 0000000..94b2a8e --- /dev/null +++ b/app/models/post_caption.ts @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon' +import { BaseModel, beforeSave, belongsTo, column } from '@adonisjs/lucid/orm' +import Post from './post.js' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import CaptionTypes from '#enums/caption_types' +import CaptionLanguages, { CaptionLanguageDesc } from '#enums/caption_languages' + +export default class PostCaption extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare postId: number + + @column() + declare type: CaptionTypes + + @column() + declare label: string + + @column() + declare language: CaptionLanguages + + @column() + declare filename: string + + @column() + declare sortOrder: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + @belongsTo(() => Post) + declare post: BelongsTo + + @beforeSave() + public static async setDefaults(row: PostCaption) { + if (!row.filename) { + row.filename = `${row.language}.${row.type}` + } + + if (!row.label) { + row.label = CaptionLanguageDesc[row.language] + } + } +} diff --git a/app/models/post_chapter.ts b/app/models/post_chapter.ts new file mode 100644 index 0000000..2ece531 --- /dev/null +++ b/app/models/post_chapter.ts @@ -0,0 +1,51 @@ +import { DateTime } from 'luxon' +import { BaseModel, belongsTo, column, computed } from '@adonisjs/lucid/orm' +import Post from './post.js' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' + +export default class PostChapter extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare postId: number + + @column() + declare start: string + + @column() + declare end: string + + @column() + declare text: string + + @column() + declare sortOrder: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + @belongsTo(() => Post) + declare post: BelongsTo + + @computed() + get startSeconds() { + const parts = this.start.split(':') + const hours = parts.length > 2 ? Number(parts[0]) : 0 + const minutes = parts.length > 2 ? Number(parts[1]) : Number(parts[0]) + const seconds = parts.length > 2 ? Number(parts[2]) : Number(parts[1]) + return hours * 3600 + minutes * 60 + seconds + } + + @computed() + get endSeconds() { + const parts = this.end.split(':') + const hours = parts.length > 2 ? Number(parts[0]) : 0 + const minutes = parts.length > 2 ? Number(parts[1]) : Number(parts[0]) + const seconds = parts.length > 2 ? Number(parts[2]) : Number(parts[1]) + return hours * 3600 + minutes * 60 + seconds + } +} diff --git a/app/validators/post.ts b/app/validators/post.ts index 75b5555..4df81ee 100644 --- a/app/validators/post.ts +++ b/app/validators/post.ts @@ -5,6 +5,8 @@ import VideoTypes from '#enums/video_types' import States from '#enums/states' import PaywallTypes from '#enums/paywall_types' import { DateTime } from 'luxon' +import CaptionTypes from '#enums/caption_types' +import CaptionLanguages from '#enums/caption_languages' export const postIndexValidator = vine.compile( vine.object({ @@ -49,7 +51,7 @@ export const postValidator = vine.compile( isFeatured: vine.boolean().optional().nullable(), isLive: vine.boolean().nullable(), videoTypeId: vine.number().enum(VideoTypes).optional(), - videoUrl: vine.string().trim().maxLength(255).url().optional().nullable(), + videoUrl: vine.string().trim().maxLength(255).optional().nullable(), videoBunnyId: vine.string().trim().maxLength(500).optional().nullable(), videoSeconds: vine.number().optional(), timezone: vine.string().trim().optional(), @@ -65,6 +67,28 @@ export const postValidator = vine.compile( }), taxonomyIds: vine.array(vine.number().exists(exists('taxonomies', 'id'))).optional(), + captions: vine + .array( + vine.object({ + id: vine.number().exists(exists('post_captions', 'id')).optional(), + type: vine.enum(CaptionTypes), + label: vine.string().maxLength(50).optional(), + language: vine.enum(CaptionLanguages), + }) + ) + .optional(), + + chapters: vine + .array( + vine.object({ + id: vine.number().exists(exists('post_chapters', 'id')).optional(), + start: vine.string().regex(/^(?:[0-5]?[0-9]):([0-5]?[0-9])$/), + end: vine.string().regex(/^(?:[0-5]?[0-9]):([0-5]?[0-9])$/), + text: vine.string().maxLength(100), + }) + ) + .optional(), + publishAt: vine .computed() .requires(['publishAtDate', 'publishAtTime', 'timezone']) diff --git a/database/migrations/1735411574023_create_post_captions_table.ts b/database/migrations/1735411574023_create_post_captions_table.ts new file mode 100644 index 0000000..a931a71 --- /dev/null +++ b/database/migrations/1735411574023_create_post_captions_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'post_captions' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('post_id').unsigned().references('id').inTable('posts').notNullable() + table.string('type', 10).notNullable().defaultTo('srt') + table.string('label', 50).notNullable() + table.string('language', 10).notNullable() + table.string('filename', 100).notNullable() + table.integer('sort_order').notNullable().defaultTo(0) + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1735411585674_create_post_chapters_table.ts b/database/migrations/1735411585674_create_post_chapters_table.ts new file mode 100644 index 0000000..f8c8ba7 --- /dev/null +++ b/database/migrations/1735411585674_create_post_chapters_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'post_chapters' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('post_id').unsigned().references('id').inTable('posts').notNullable() + table.string('start', 10).notNullable() + table.string('end', 10).notNullable() + table.string('text', 100).notNullable() + table.integer('sort_order').notNullable().defaultTo(0) + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/inertia/components/VideoPreview.vue b/inertia/components/VideoPreview.vue index 9a19c41..c1ca36a 100644 --- a/inertia/components/VideoPreview.vue +++ b/inertia/components/VideoPreview.vue @@ -14,12 +14,13 @@ const emit = defineEmits(['update:duration']) const videoId = computed(() => { if (props.videoTypeId == VideoTypes.BUNNY) return props.bunnyId - if (props.videoTypeId == VideoTypes.YOUTUBE) return getYouTubeVideoid(props.videoUrl) + if (props.videoTypeId == VideoTypes.YOUTUBE) return getYouTubeVideoId(props.videoUrl) + if (props.videoTypeId == VideoTypes.R2) return props.videoUrl }) const showEmbed = computed(() => isSourceValid(videoId.value)) -function getYouTubeVideoid(source: string | null) { +function getYouTubeVideoId(source: string | null) { if (!source) return '' return source .replace('www.', '') @@ -35,6 +36,7 @@ function isSourceValid(source?: string | null) { if (!source) return false if (props.videoTypeId == VideoTypes.BUNNY) return !!source if (props.videoTypeId == VideoTypes.YOUTUBE) return !!source.length + if (props.videoTypeId == VideoTypes.R2) return !!source.length } watch( @@ -67,7 +69,7 @@ function onYouTubeInit() { } } -function onBunnyReady(event: any) { +function onVideoReady(event: any) { emit('update:duration', Math.round(event.target.duration)) } @@ -85,7 +87,7 @@ function onBunnyReady(event: any) {

No Video

Enter a valid video id to add a video to this post

-