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>
335 lines
11 KiB
Go
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())
|
|
}
|
|
}
|