Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upload Custom Thumbnail for a Lecture #1479

Draft
wants to merge 4 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions api/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {
thumbs.GET(":fid", routes.getThumbs)
thumbs.GET("/live", routes.getLiveThumbs)
thumbs.GET("/vod", routes.getVODThumbs)
thumbs.POST("/customlive", routes.putCustomLiveThumbnail)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need two routes to do the same thing? I'd argue there's no need to distinguish livestreams from vods when uploading thumbnails

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo the logical method and path would be something like POST /api/stream/:streamID/thumbnails

Suggested change
thumbs.POST("/customlive", routes.putCustomLiveThumbnail)
thumbs.POST("/", routes.putCustomLiveThumbnail)

thumbs.POST("/customvod", routes.putCustomLiveThumbnail)
}
}
{
Expand Down Expand Up @@ -894,3 +896,45 @@ func (r streamRoutes) updateChatEnabled(c *gin.Context) {
return
}
}

func (r streamRoutes) putCustomLiveThumbnail(c *gin.Context) {
tumLiveContext := c.MustGet("TUMLiveContext").(tools.TUMLiveContext)
streamID := tumLiveContext.Stream.ID
course := tumLiveContext.Course
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use our RequestError to abort:

_ = c.AbortWithError(http.StatusForbidden, tools.RequestError{
	Status:        http.StatudBadRequest,
	CustomMessage: "Couldn't read file",
	Err:           err,
})

return
}

filename := file.Filename
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the filename if it's unused

fileUuid := uuid.NewV1()

filesFolder := fmt.Sprintf("%s/%s.%d/%s.%s/files",
tools.Cfg.Paths.Mass,
course.Name, course.Year,
course.Name, course.TeachingTerm)

path := fmt.Sprintf("%s/%s%s", filesFolder, fileUuid, filepath.Ext(filename))
Comment on lines +913 to +918
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use path.Join from the standard library to construct this filepath:
https://pkg.go.dev/path#example-Join


//tempFilePath := pathprovider.LiveThumbnail(strconv.Itoa(int(streamID)))
if err := c.SaveUploadedFile(file, path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use RequestError here too

return
}

thumb := model.File{
StreamID: streamID,
Path: path,
Filename: file.Filename,
Type: model.FILETYPE_THUMB_CAM,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc this filetype is for the scrubbar, not the preview. To be sure, use FILETYPE_THUMB_LG_CAM_PRES, which is always elected default

}

fileDao := dao.NewFileDao()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to create a new DAO, just use r.DaoWrapper.FileDao

if err := fileDao.SetThumbnail(streamID, thumb); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set thumbnail"})
return
}

c.JSON(http.StatusOK, gin.H{"message": "Thumbnail uploaded successfully"})
}
122 changes: 62 additions & 60 deletions model/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,44 @@ import (
type Stream struct {
gorm.Model

Name string `gorm:"index:,class:FULLTEXT"`
Description string `gorm:"type:text;index:,class:FULLTEXT"`
CourseID uint
Start time.Time `gorm:"not null"`
End time.Time `gorm:"not null"`
ChatEnabled bool `gorm:"default:null"`
RoomName string
RoomCode string
EventTypeName string
TUMOnlineEventID uint
SeriesIdentifier string `gorm:"default:null"`
StreamKey string `gorm:"not null"`
PlaylistUrl string
PlaylistUrlPRES string
PlaylistUrlCAM string
LiveNow bool `gorm:"not null"`
LiveNowTimestamp time.Time `gorm:"default:null;column:live_now_timestamp"`
Recording bool
Premiere bool `gorm:"default:null"`
Ended bool `gorm:"default:null"`
Chats []Chat
Stats []Stat
Units []StreamUnit
VodViews uint `gorm:"default:0"` // todo: remove me before next semester
StartOffset uint `gorm:"default:null"`
EndOffset uint `gorm:"default:null"`
LectureHallID uint `gorm:"default:null"`
Silences []Silence
Files []File `gorm:"foreignKey:StreamID"`
ThumbInterval uint32 `gorm:"default:null"`
StreamName string
Duration sql.NullInt32 `gorm:"default:null"`
StreamWorkers []Worker `gorm:"many2many:stream_workers;"`
StreamProgresses []StreamProgress `gorm:"foreignKey:StreamID"`
VideoSections []VideoSection
TranscodingProgresses []TranscodingProgress `gorm:"foreignKey:StreamID"`
Private bool `gorm:"not null;default:false"`
Name string `gorm:"index:,class:FULLTEXT"`
Description string `gorm:"type:text;index:,class:FULLTEXT"`
CourseID uint
Start time.Time `gorm:"not null"`
End time.Time `gorm:"not null"`
ChatEnabled bool `gorm:"default:null"`
CustomThumbnailEnabled bool `gorm:"default:false"`
RoomName string
RoomCode string
EventTypeName string
TUMOnlineEventID uint
SeriesIdentifier string `gorm:"default:null"`
StreamKey string `gorm:"not null"`
PlaylistUrl string
PlaylistUrlPRES string
PlaylistUrlCAM string
LiveNow bool `gorm:"not null"`
LiveNowTimestamp time.Time `gorm:"default:null;column:live_now_timestamp"`
Recording bool
Premiere bool `gorm:"default:null"`
Ended bool `gorm:"default:null"`
Chats []Chat
Stats []Stat
Units []StreamUnit
VodViews uint `gorm:"default:0"` // todo: remove me before next semester
StartOffset uint `gorm:"default:null"`
EndOffset uint `gorm:"default:null"`
LectureHallID uint `gorm:"default:null"`
Silences []Silence
Files []File `gorm:"foreignKey:StreamID"`
ThumbInterval uint32 `gorm:"default:null"`
StreamName string
Duration sql.NullInt32 `gorm:"default:null"`
StreamWorkers []Worker `gorm:"many2many:stream_workers;"`
StreamProgresses []StreamProgress `gorm:"foreignKey:StreamID"`
VideoSections []VideoSection
TranscodingProgresses []TranscodingProgress `gorm:"foreignKey:StreamID"`
Private bool `gorm:"not null;default:false"`

Watched bool `gorm:"-"` // Used to determine if stream is watched when loaded for a specific user.
}
Expand Down Expand Up @@ -337,29 +338,30 @@ func (s Stream) GetJson(lhs []LectureHall, course Course) gin.H {
}

return gin.H{
"lectureId": s.Model.ID,
"courseId": s.CourseID,
"seriesIdentifier": s.SeriesIdentifier,
"name": s.Name,
"description": s.Description,
"lectureHallId": s.LectureHallID,
"lectureHallName": lhName,
"streamKey": s.StreamKey,
"isLiveNow": s.LiveNow,
"isRecording": s.Recording,
"isConverting": s.IsConverting(),
"transcodingProgresses": s.TranscodingProgresses,
"isPast": s.IsPast(),
"hasStats": s.Stats != nil,
"files": files,
"color": s.Color(),
"start": s.Start,
"end": s.End,
"isChatEnabled": s.ChatEnabled,
"courseSlug": course.Slug,
"private": s.Private,
"downloadableVods": s.GetVodFiles(),
"videoSections": videoSections,
"lectureId": s.Model.ID,
"courseId": s.CourseID,
"seriesIdentifier": s.SeriesIdentifier,
"name": s.Name,
"description": s.Description,
"lectureHallId": s.LectureHallID,
"lectureHallName": lhName,
"streamKey": s.StreamKey,
"isLiveNow": s.LiveNow,
"isRecording": s.Recording,
"isConverting": s.IsConverting(),
"transcodingProgresses": s.TranscodingProgresses,
"isPast": s.IsPast(),
"hasStats": s.Stats != nil,
"files": files,
"color": s.Color(),
"start": s.Start,
"end": s.End,
"isChatEnabled": s.ChatEnabled,
"isCustomThumbnailEnabled": s.CustomThumbnailEnabled,
"courseSlug": course.Slug,
"private": s.Private,
"downloadableVods": s.GetVodFiles(),
"videoSections": videoSections,
}
}

Expand Down
29 changes: 29 additions & 0 deletions web/template/partial/course/manage/lecture-management-card.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,35 @@
<span class="ml-3 text-sm font-medium text-3">Chat Enabled</span>
</label>
</section>
<section>
<label class="relative inline-flex items-center cursor-pointer left-3">
<input type="checkbox" name="isCustomThumbnailEnabled" class="sr-only peer" x-bind-change-set="changeSet" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-600
dark:peer-focus:ring-indigo-600 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full
peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5
after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-indigo-600"></div>
<span class="ml-3 text-sm font-medium text-3">Upload Custom Thumbnail</span>
</label>
<section>
<article x-data="{ id: $id('text-input') }"
class="w-full">
<label :for="id" class="hidden">Lecture description</label>
<textarea :id="id"
@drop.prevent="(e) => onCustomThumbnailUpload(e)"
@dragover.prevent=""
class="tl-textarea grow"
placeholder="Drop thumbnail here."
autocomplete="off"
x-bind-change-set:description="changeSet"></textarea>
</article>

</section>
<!--input type="file" id="thumbnailUpload" class="hidden" accept="image/*" @change="onAttachmentFileDrop"-->

</section>


<div>
<button :disabled="isSaving" @click="discardEdit();"
class="px-8 py-3 text-2 text-white rounded bg-indigo-500/70 hover:bg-indigo-500/90 dark:bg-indigo-500/10 disabled:opacity-20 dark:hover:bg-indigo-500/20 mr-4">
Expand Down
20 changes: 20 additions & 0 deletions web/ts/api/admin-lecture-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface UpdateLectureMetaRequest {
description?: string;
lectureHallId?: number;
isChatEnabled?: boolean;
isCustomThumbnailEnabled?: boolean;
}

export class LectureFile {
Expand Down Expand Up @@ -167,6 +168,7 @@ export interface Lecture {
hasStats: boolean;
isChatEnabled: boolean;
isConverting: boolean;
isCustomThumbnailEnabled: boolean;
isLiveNow: boolean;
isPast: boolean;
isRecording: boolean;
Expand Down Expand Up @@ -265,6 +267,15 @@ export const AdminLectureList = {
);
}

/* if (request.isCustomThumbnailEnabled !== undefined) {
promises.push(
put(`/api/stream/${lectureId}/customThumbnail/enabled`, {
lectureId,
thumbnailFile: request.thumbnailFile,
}),
);
}*/

const errors = (await Promise.all(promises)).filter((res) => res.status !== StatusCodes.OK);
if (errors.length > 0) {
console.error(errors);
Expand Down Expand Up @@ -371,6 +382,15 @@ export const AdminLectureList = {
) => {
return await uploadFile(`/api/stream/${lectureId}/files?type=file`, file, listener);
},
uploadThumbnailFile: async (
courseId: number,
lectureId: number,
file: File,
listener: PostFormDataListener = {},
) => {
return await uploadFile(`/api/stream/${lectureId}/thumbs/customlive`, file, listener);

},

/**
* Upload a url as attachment for a lecture
Expand Down
22 changes: 22 additions & 0 deletions web/ts/data-store/admin-lecture-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,26 @@ export class AdminLectureListProvider extends StreamableMapProvider<number, Lect
) {
await AdminLectureList.uploadVideo(courseId, lectureId, videoType, file, listener);
}

async uploadThumbnail(courseId: number, lectureId: number, file: File) {

const res = await AdminLectureList.uploadThumbnailFile(courseId, lectureId, file);
const newFile = new LectureFile({
id: JSON.parse(res.responseText),
fileType: 2,
friendlyName: file.name,
});

this.data[courseId] = (await this.getData(courseId)).map((s) => {
if (s.lectureId === lectureId) {
return {
...s,
files: [...s.files, newFile],
};
}
return s;
});
await this.triggerUpdate(courseId);

}
}
19 changes: 18 additions & 1 deletion web/ts/edit-course.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
* Save changes send them to backend and commit change set.
*/
async saveEdit() {
const { courseId, lectureId, name, description, lectureHallId, isChatEnabled, videoSections } =
const { courseId, lectureId, name, description, lectureHallId, isChatEnabled, isCustomThumbnailEnabled, videoSections } =
this.lectureData;
const changedKeys = this.changeSet.changedKeys();

Expand All @@ -331,6 +331,7 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
description: changedKeys.includes("description") ? description : undefined,
lectureHallId: changedKeys.includes("lectureHallId") ? lectureHallId : undefined,
isChatEnabled: changedKeys.includes("isChatEnabled") ? isChatEnabled : undefined,
isCustomThumbnailEnabled: changedKeys.includes("isCustomThumbnailEnabled") ? isCustomThumbnailEnabled : undefined,
},
options: {
saveSeries: this.uiEditMode === UIEditMode.series,
Expand Down Expand Up @@ -379,6 +380,22 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
this.changeSet.commit({ discardKeys: this.videoFiles.map((v) => v.info.key) });
this.uiEditMode = UIEditMode.none;
},
onCustomThumbnailUpload(e){
if (e.dataTransfer.items) {
const item = e.dataTransfer.items[0];
const { kind } = item;
switch (kind) {
case "file": {
DataStore.adminLectureList.uploadThumbnail(
this.lectureData.courseId,
this.lectureData.lectureId,
item.getAsFile(),
);
break;
}

}
}},
} as AlpineComponent;
}

Expand Down