mattermost-plugin-shelfmark/server/job.go
Felipe M. 3691d9eba2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci / plugin-ci (push) Has been cancelled
Add book request functionality to RHS panel
Server:
- Add HTTP API endpoints (ServeHTTP) for search, releases, request,
  config, and cover image proxy
- Add SearchBooksWithLimit to shelfmark client for multi-result searches
- Add SelectedRelease field to DownloadTask for user-chosen releases
- Honor SelectedRelease in background job instead of auto-selecting

Webapp:
- Add Client4-based API client for plugin endpoints
- Implement 3-view RHS panel: search, results, and book detail
- Search view with language dropdown (English/Español) and query input
- Results view with cover thumbnails, titles, and authors
- Detail view with release list and per-release request buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:52:11 +01:00

335 lines
11 KiB
Go

package main
import (
"fmt"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"git.nakama.town/fmartingr/mattermost-plugin-shelfmark/server/shelfmark"
)
// runDownloadJob is the background job that processes pending download tasks.
// It is scheduled via cluster.Schedule to run every 5 seconds.
func (p *Plugin) runDownloadJob() {
config := p.getConfiguration()
if err := config.IsValid(); err != nil {
// Plugin not configured; nothing to do.
return
}
tasks, err := p.taskStore.ListActiveTasks()
if err != nil {
p.API.LogError("Failed to list active download tasks", "error", err.Error())
return
}
if len(tasks) == 0 {
return
}
for _, task := range tasks {
p.processTask(task)
}
}
// processTask handles a single download task based on its current status.
func (p *Plugin) processTask(task *DownloadTask) {
// Check if task is too old and should be considered stale.
if time.Since(task.CreatedAt) > taskMaxAge {
p.API.LogWarn("Download task expired", "task_id", task.ID, "title", task.BookTitle)
p.failTask(task,
"Task timed out after "+taskMaxAge.String(),
T(task.RequesterLocale, MsgDownloadTimeout, task.BookTitle))
return
}
switch task.Status {
case TaskStatusPending:
p.processTaskPending(task)
case TaskStatusQueued, TaskStatusDownloading:
p.processTaskQueued(task)
case TaskStatusComplete:
p.processTaskComplete(task)
}
}
// processTaskPending finds releases for the book and queues the best one for download.
func (p *Plugin) processTaskPending(task *DownloadTask) {
p.API.LogDebug("Processing pending task", "task_id", task.ID, "title", task.BookTitle)
releases, err := p.shelfmarkClient.GetReleases(task.BookProvider, task.BookProviderID, task.Language)
if err != nil {
p.API.LogError("Failed to get releases", "task_id", task.ID, "error", err.Error())
p.failTask(task,
"Failed to find downloadable releases: "+err.Error(),
T(task.RequesterLocale, MsgNoReleases, task.BookTitle))
return
}
// Log partial errors from Shelfmark sources if any.
if len(releases.Errors) > 0 {
p.API.LogWarn("Shelfmark reported partial errors for releases",
"task_id", task.ID, "errors", strings.Join(releases.Errors, "; "))
}
// Extract the localized title from the releases book metadata if available.
if task.Language != "" {
localizedTitle := releases.Book.LocalizedTitle(task.Language)
if localizedTitle != task.BookTitle {
p.API.LogDebug("Found localized title", "task_id", task.ID, "language", task.Language, "localized_title", localizedTitle)
task.LocalizedTitle = localizedTitle
}
}
// Use the user-selected release if provided (from RHS panel), otherwise pick the first.
var release *shelfmark.Release
if task.SelectedRelease != nil {
release = task.SelectedRelease
} else {
if len(releases.Releases) == 0 {
p.API.LogWarn("No releases found for book", "task_id", task.ID, "title", task.BookTitle)
p.failTask(task,
"No downloadable releases found",
T(task.RequesterLocale, MsgNoReleasesFound, task.BookTitle))
return
}
release = &releases.Releases[0]
}
// Check if this release is already in the Shelfmark queue before trying to queue it.
// This avoids a 500 error from Shelfmark when the release is already queued or complete.
statusMap, err := p.shelfmarkClient.GetStatus()
if err != nil {
p.API.LogWarn("Failed to check Shelfmark status before queueing", "task_id", task.ID, "error", err.Error())
// Continue to try queueing anyway.
} else if existingStatus, found := shelfmark.GetTaskStatus(statusMap, release.SourceID); found {
p.API.LogInfo("Release already exists in Shelfmark queue", "task_id", task.ID, "shelfmark_task_id", release.SourceID, "status", existingStatus)
task.ShelfmarkTaskID = release.SourceID
switch existingStatus {
case "complete":
task.Status = TaskStatusComplete
if saveErr := p.taskStore.SaveTask(task); saveErr != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", saveErr.Error())
}
return
case "error", "cancelled":
// Previous attempt failed; fall through to re-queue below.
default:
// Already in progress (queued, downloading, locating, resolving).
task.Status = TaskStatusQueued
if saveErr := p.taskStore.SaveTask(task); saveErr != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", saveErr.Error())
}
return
}
}
// Queue the download on Shelfmark.
queueResp, err := p.shelfmarkClient.QueueDownload(release)
if err != nil {
p.API.LogError("Failed to queue download", "task_id", task.ID, "error", err.Error())
p.failTask(task,
"Failed to queue download: "+err.Error(),
T(task.RequesterLocale, MsgDownloadStartFailed, task.BookTitle))
return
}
if queueResp.Error != "" {
p.API.LogError("Shelfmark returned error when queueing download", "task_id", task.ID, "error", queueResp.Error)
p.failTask(task,
"Shelfmark error: "+queueResp.Error,
T(task.RequesterLocale, MsgShelfmarkError, task.BookTitle))
return
}
task.ShelfmarkTaskID = release.SourceID
task.Status = TaskStatusQueued
if err := p.taskStore.SaveTask(task); err != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", err.Error())
return
}
p.API.LogInfo("Download queued on Shelfmark", "task_id", task.ID, "shelfmark_task_id", task.ShelfmarkTaskID, "title", task.BookTitle)
}
// processTaskQueued checks the Shelfmark download status.
func (p *Plugin) processTaskQueued(task *DownloadTask) {
statusMap, err := p.shelfmarkClient.GetStatus()
if err != nil {
p.API.LogWarn("Failed to get Shelfmark status", "task_id", task.ID, "error", err.Error())
return // Will retry on next poll.
}
status, found := shelfmark.GetTaskStatus(statusMap, task.ShelfmarkTaskID)
if !found {
// Task not found in status; it may have already been processed and removed.
if time.Since(task.UpdatedAt) > 5*time.Minute {
p.API.LogWarn("Queued task not found in Shelfmark status, marking as failed", "task_id", task.ID)
p.failTask(task,
"Task disappeared from Shelfmark queue",
T(task.RequesterLocale, MsgTrackingLost, task.EffectiveTitle()))
}
return
}
switch status {
case "complete":
p.API.LogInfo("Download complete on Shelfmark", "task_id", task.ID, "title", task.BookTitle)
task.Status = TaskStatusComplete
if err := p.taskStore.SaveTask(task); err != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", err.Error())
}
case "error", "cancelled":
p.API.LogWarn("Download failed on Shelfmark", "task_id", task.ID, "status", status)
p.failTask(task,
fmt.Sprintf("Shelfmark download %s", status),
T(task.RequesterLocale, MsgDownloadStatus, task.BookTitle, status))
default:
// Still in progress (queued, downloading, locating, resolving, etc.).
task.Status = TaskStatusDownloading
if err := p.taskStore.SaveTask(task); err != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", err.Error())
}
}
}
// processTaskComplete downloads the file from Shelfmark, creates the book post
// with the cover image, and replies with the book file.
func (p *Plugin) processTaskComplete(task *DownloadTask) {
p.API.LogInfo("Downloading file from Shelfmark", "task_id", task.ID, "title", task.BookTitle)
// Download the book file from Shelfmark.
fileData, filename, err := p.shelfmarkClient.DownloadFile(task.ShelfmarkTaskID)
if err != nil {
p.API.LogError("Failed to download file from Shelfmark", "task_id", task.ID, "error", err.Error())
// If the file is not ready yet, retry later.
if time.Since(task.UpdatedAt) < 2*time.Minute {
return
}
p.failTask(task,
"Failed to download file: "+err.Error(),
T(task.RequesterLocale, MsgDownloadFailed, task.EffectiveTitle()))
return
}
// Download the cover image if available.
var coverFileIDs []string
if task.BookCoverURL != "" {
coverData, coverFilename, coverErr := p.shelfmarkClient.DownloadCover(task.BookCoverURL)
if coverErr != nil {
p.API.LogWarn("Failed to download cover image", "task_id", task.ID, "error", coverErr.Error())
} else {
coverInfo, appErr := p.API.UploadFile(coverData, task.ChannelID, coverFilename)
if appErr != nil {
p.API.LogWarn("Failed to upload cover image", "task_id", task.ID, "error", appErr.Error())
} else {
coverFileIDs = append(coverFileIDs, coverInfo.Id)
}
}
}
// Render the post message using the (possibly localized) title.
postMessage, err := p.renderPostMessage(task)
if err != nil {
p.API.LogError("Failed to render post message", "task_id", task.ID, "error", err.Error())
postMessage = "### " + task.EffectiveTitle()
}
// Create the book announcement post with the cover image.
bookPost := &model.Post{
UserId: p.botUserID,
ChannelId: task.ChannelID,
Message: postMessage,
FileIds: coverFileIDs,
}
createdPost, appErr := p.API.CreatePost(bookPost)
if appErr != nil {
p.API.LogError("Failed to create book post", "task_id", task.ID, "error", appErr.Error())
p.failTask(task,
"Failed to create post: "+appErr.Error(),
T(task.RequesterLocale, MsgPostFailed, task.EffectiveTitle()))
return
}
task.PostID = createdPost.Id
// Upload the book file to Mattermost.
fileInfo, appErr := p.API.UploadFile(fileData, task.ChannelID, filename)
if appErr != nil {
p.API.LogError("Failed to upload file to Mattermost", "task_id", task.ID, "error", appErr.Error())
p.failTask(task,
"Failed to upload file: "+appErr.Error(),
T(task.RequesterLocale, MsgUploadFailed, task.EffectiveTitle()))
return
}
// Create a reply to the book post with the file attached.
replyPost := &model.Post{
UserId: p.botUserID,
ChannelId: task.ChannelID,
RootId: createdPost.Id,
Message: "",
FileIds: []string{fileInfo.Id},
}
if _, appErr := p.API.CreatePost(replyPost); appErr != nil {
p.API.LogError("Failed to create file reply post", "task_id", task.ID, "error", appErr.Error())
p.failTask(task,
"Failed to create reply post: "+appErr.Error(),
T(task.RequesterLocale, MsgAttachFailed, task.EffectiveTitle()))
return
}
task.Status = TaskStatusUploaded
if err := p.taskStore.SaveTask(task); err != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", err.Error())
return
}
p.API.LogInfo("Book posted successfully", "task_id", task.ID, "title", task.BookTitle, "filename", filename, "post_id", createdPost.Id)
}
// failTask marks a task as failed, persists it, and notifies the requester.
func (p *Plugin) failTask(task *DownloadTask, errMsg, userMsg string) {
task.Status = TaskStatusFailed
task.ErrorMessage = errMsg
if err := p.taskStore.SaveTask(task); err != nil {
p.API.LogError("Failed to save task", "task_id", task.ID, "error", err.Error())
}
if userMsg != "" {
p.notifyRequester(task, userMsg)
}
}
// notifyRequester sends a DM to the user who requested the book
// about errors or status updates. Uses a DM channel with the bot.
func (p *Plugin) notifyRequester(task *DownloadTask, message string) {
if task.RequestedBy == "" {
return
}
// Create a DM channel between the bot and the requester.
dmChannel, appErr := p.API.GetDirectChannel(p.botUserID, task.RequestedBy)
if appErr != nil {
p.API.LogWarn("Failed to get DM channel for requester notification", "task_id", task.ID, "error", appErr.Error())
return
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: dmChannel.Id,
Message: message,
}
if _, appErr := p.API.CreatePost(post); appErr != nil {
p.API.LogError("Failed to send requester notification", "task_id", task.ID, "error", appErr.Error())
}
}