diff --git a/internal/api/http/handlers/donat/donat.go b/internal/api/http/handlers/donat/donat.go index c3b0984..d44586e 100644 --- a/internal/api/http/handlers/donat/donat.go +++ b/internal/api/http/handlers/donat/donat.go @@ -713,6 +713,40 @@ func GetDonatForPlaying(donatService model.DonatService) echo.HandlerFunc { } } +// UpdateStreamerOnline godoc +// @Summary Update streamer online status +// @Description Marks streamer as online in the system +// @Tags Donate +// @Accept json +// @Produce json +// @Param streamer-id path string true "Streamer ID (numeric)" +// @Success 200 {object} map[string]interface{} "Success status" +// @Failure 400 {object} echo.HTTPError "Invalid streamer ID" +// @Failure 500 {object} echo.HTTPError "Internal server error" +// @Router /update-streamer-online/{streamer-id} [put] +func UpdateStreamerOnline(donatService model.DonatService) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := context.Background() + streamerIDParam := c.Param("streamer-id") + + streamerID, err := strconv.Atoi(streamerIDParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid streamer ID format") + } + + err = donatService.UpdateStreamerOnline(ctx, streamerID) + if err != nil { + slog.Error("Failed to update streamer online status", "error", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update online status") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "message": "streamer online status updated", + }) + } +} + // UpdateLoginDonatePage godoc // @Summary Update streamer login // @Description Updates the streamer login associated with the donate page diff --git a/internal/app/http/app.go b/internal/app/http/app.go index 76bbedf..a15b5e7 100644 --- a/internal/app/http/app.go +++ b/internal/app/http/app.go @@ -107,6 +107,7 @@ func IncludeDonatHandlers( server.GET(PREFIX+"/get-donat-for-playing/:streamer-id", GetDonatForPlaying(donatService)) server.GET(PREFIX+"/text-after-donat/:order-id", GetMessageAfterDonat(donatService)) server.POST(PREFIX+"/update-login-donate", UpdateLoginDonatePage(donatService)) + server.PUT(PREFIX+"/update-streamer-online/:streamer-id", UpdateStreamerOnline(donatService)) } func IncludeWidgetHandlers( diff --git a/internal/docs/docs.go b/internal/docs/docs.go index bb1000d..180a37b 100644 --- a/internal/docs/docs.go +++ b/internal/docs/docs.go @@ -1201,6 +1201,51 @@ const docTemplate = `{ } } }, + "/update-streamer-online/{streamer-id}": { + "put": { + "description": "Marks streamer as online in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Donate" + ], + "summary": "Update streamer online status", + "parameters": [ + { + "type": "string", + "description": "Streamer ID (numeric)", + "name": "streamer-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success status", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid streamer ID", + "schema": { + "$ref": "#/definitions/echo.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/echo.HTTPError" + } + } + } + } + }, "/voice-settings": { "get": { "security": [ diff --git a/internal/docs/swagger.json b/internal/docs/swagger.json index 49a719c..a7a1e60 100644 --- a/internal/docs/swagger.json +++ b/internal/docs/swagger.json @@ -1194,6 +1194,51 @@ } } }, + "/update-streamer-online/{streamer-id}": { + "put": { + "description": "Marks streamer as online in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Donate" + ], + "summary": "Update streamer online status", + "parameters": [ + { + "type": "string", + "description": "Streamer ID (numeric)", + "name": "streamer-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success status", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid streamer ID", + "schema": { + "$ref": "#/definitions/echo.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/echo.HTTPError" + } + } + } + } + }, "/voice-settings": { "get": { "security": [ diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml index 5abb707..d8773a4 100644 --- a/internal/docs/swagger.yaml +++ b/internal/docs/swagger.yaml @@ -1328,6 +1328,36 @@ paths: summary: Update streamer login tags: - Donate + /update-streamer-online/{streamer-id}: + put: + consumes: + - application/json + description: Marks streamer as online in the system + parameters: + - description: Streamer ID (numeric) + in: path + name: streamer-id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success status + schema: + additionalProperties: true + type: object + "400": + description: Invalid streamer ID + schema: + $ref: '#/definitions/echo.HTTPError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/echo.HTTPError' + summary: Update streamer online status + tags: + - Donate /voice-settings: get: consumes: diff --git a/internal/model/interfaces.go b/internal/model/interfaces.go index ab7f4a1..508d27c 100644 --- a/internal/model/interfaces.go +++ b/internal/model/interfaces.go @@ -4,6 +4,7 @@ import ( "donat-widget/internal/model/api" "github.com/google/uuid" "mime/multipart" + "time" ) import ( @@ -108,6 +109,7 @@ type DonatService interface { UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error UpdateAvatarStreamer(token, avatarId string) error GetTextAfterDonatByOrder(ctx context.Context, orderID uuid.UUID) (string, error) + UpdateStreamerOnline(ctx context.Context, streamerID int) error } type DonatRepo interface { @@ -178,6 +180,8 @@ type DonatRepo interface { GetPlayingDonat(ctx context.Context, streamerID int) (PlayingDonat, error) UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error + CreateStreamerOnline(ctx context.Context, streamerID int) error + GetLastStreamerOnline(ctx context.Context, streamerID int) (time.Time, error) } type TargetService interface { diff --git a/internal/model/sql/model.go b/internal/model/sql/model.go index 8cd3418..828163d 100644 --- a/internal/model/sql/model.go +++ b/internal/model/sql/model.go @@ -130,6 +130,12 @@ CREATE TABLE IF NOT EXISTS streamers_widgets_pages ( id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(), streamer_id INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS streamers_online ( + id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(), + streamer_id INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, +); CREATE OR REPLACE FUNCTION update_updated_at() diff --git a/internal/model/sql/query.go b/internal/model/sql/query.go index 09ae50d..2c97e0c 100644 --- a/internal/model/sql/query.go +++ b/internal/model/sql/query.go @@ -589,3 +589,9 @@ SELECT streamer_id FROM streamers_widgets_pages WHERE id = @widget_page_id` var UpdateStreamerLogin = ` UPDATE donate_pages SET streamer_login = @streamer_login WHERE streamer_id = @streamer_id RETURNING streamer_login;` + +var InsertStreamerOnline = ` +INSERT INTO streamers_online (streamer_id) VALUES (@streamer_id)` + +var GetLastStreamerOnline = ` +SELECT created_at FROM streamers_online WHERE streamer_id = @streamer_id` diff --git a/internal/model/widget-templates.go b/internal/model/widget-templates.go index eb35948..2d1a581 100644 --- a/internal/model/widget-templates.go +++ b/internal/model/widget-templates.go @@ -64,6 +64,20 @@ func GetTemplate1(streamerID int, donatHost, ttsHost string) string { let widgetUrl = 'https://%s/api'; let ttsUrl = 'https://%s/api/tts'; +// Функция для периодических запросов онлайн-статуса +function startHeartbeat(streamerID) { + setInterval(async () => { + try { + await fetch(widgetUrl + '/update-streamer-online/' + streamerID, { + method: 'PUT', + headers: {'Content-Type': 'application/json'} + }); + } catch (error) { + console.error('Heartbeat error:', error); + } + }, 10000); // 10 секунд +} + function createTextWithAmount(text, amount) { const container = document.createElement('div'); container.className = 'text-container'; @@ -120,7 +134,7 @@ function playAudio(url, volume, signal) { if (resolved) return; resolved = true; audio.pause(); - audio.src = ""; // Release resources + audio.src = ""; audio.removeEventListener('ended', onEnded); audio.removeEventListener('error', onError); reject(new DOMException('Aborted', 'AbortError')); @@ -148,16 +162,15 @@ function playAudio(url, volume, signal) { audio.addEventListener('error', onError, { once: true }); audio.play().catch(err => { - // play() can reject if interrupted or no user gesture if (resolved) return; resolved = true; signal?.removeEventListener('abort', onAbort); audio.removeEventListener('ended', onEnded); audio.removeEventListener('error', onError); if (err.name === 'AbortError' && signal?.aborted) { - reject(new DOMException('Aborted', 'AbortError')); // Propagate abort + reject(new DOMException('Aborted', 'AbortError')); } else { - reject(err); // Other play() error + reject(err); } }); }); @@ -208,7 +221,7 @@ function playSpeech(text, voiceSettings, signal) { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(requestBody), - signal: signal // Pass signal to fetch + signal: signal }) .then(response => { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); @@ -241,10 +254,9 @@ function playSpeech(text, voiceSettings, signal) { return audio.play(); }) .catch(error => { - // This catches errors from fetch, blob processing, or audio.play() - if (resolved) return; // Already handled - signal?.removeEventListener('abort', onAbort); // Ensure listener removed - cleanupAndReject(error); // Pass the original error + if (resolved) return; + signal?.removeEventListener('abort', onAbort); + cleanupAndReject(error); }); }); } @@ -273,26 +285,24 @@ async function playMedia(donat, voiceSettings) { ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal); } } catch (error) { - if (error.name !== 'AbortError') { // Only re-throw non-abort errors + if (error.name !== 'AbortError') { console.error('Error during media playback:', error); throw error; } - // If AbortError, it's handled by the timeout logic or external abort. } })(); const timeoutPromise = new Promise((resolve, reject) => { timeoutId = setTimeout(() => { - controller.abort(); // Signal all operations to stop - resolve('timeout'); // Resolve to indicate timeout completion - }, (donat.duration || 0) * 1000); // Use donat.duration, default to 0 if undefined + controller.abort(); + resolve('timeout'); + }, (donat.duration || 0) * 1000); }); try { await Promise.race([mediaOperation, timeoutPromise]); } finally { - clearTimeout(timeoutId); // Crucial: clear timeout if mediaOperation finished first or errored - // Ensure audio elements are stopped and reset if they were initiated and not properly cleaned up by abort + clearTimeout(timeoutId); if (audioElement && !audioElement.paused) { audioElement.pause(); audioElement.currentTime = 0; @@ -312,6 +322,8 @@ function clearContainer(container) { async function widgetView() { const streamerID = '%v'; + startHeartbeat(streamerID); // Запускаем heartbeat + const contentDiv = document.getElementById('content'); if (!contentDiv) { @@ -321,15 +333,15 @@ async function widgetView() { while (true) { const iterationStart = Date.now(); - let currentDonat = null; // Store current donat for duration calculation + let currentDonat = null; try { const donatData = await getDonatInfo(streamerID); if (!donatData || Object.keys(donatData).length === 0) { - await new Promise(r => setTimeout(r, 5000)); // Wait before retrying + await new Promise(r => setTimeout(r, 5000)); continue; } - currentDonat = donatData; // Assign to currentDonat + currentDonat = donatData; clearContainer(contentDiv); @@ -363,7 +375,6 @@ async function widgetView() { voice_enabled: currentDonat.voice_enabled }; - // playMedia will ensure audio plays for at most currentDonat.duration await playMedia(currentDonat, voiceSettings); if (currentDonat.order_id) { @@ -379,18 +390,14 @@ async function widgetView() { } } catch (error) { - // Log errors from getDonatInfo, playMedia, or other processing console.error('Ошибка обработки доната в цикле:', error); - // If error, maybe wait a bit before next iteration to avoid spamming - if (!currentDonat) { // e.g. error in getDonatInfo + if (!currentDonat) { await new Promise(r => setTimeout(r, 5000)); continue; } } const elapsedMs = Date.now() - iterationStart; - // Total display time for the donation (visuals + audio) - // Use currentDonat.duration if available, otherwise default (e.g., 5 seconds) const targetDisplayTimeMs = (currentDonat?.duration || 5) * 1000; const remainingTimeMs = Math.max(0, targetDisplayTimeMs - elapsedMs); @@ -398,7 +405,6 @@ async function widgetView() { if (remainingTimeMs > 0) { await new Promise(r => setTimeout(r, remainingTimeMs)); } - // If elapsedMs already exceeds targetDisplayTimeMs, loop will continue immediately } } diff --git a/internal/repository/donat/donat.go b/internal/repository/donat/donat.go index fdb7763..c3b5afe 100644 --- a/internal/repository/donat/donat.go +++ b/internal/repository/donat/donat.go @@ -881,3 +881,36 @@ func (repoDonat *RepoDonat) UpdateStreamerLogin( } return nil } + +func (repoDonat *RepoDonat) CreateStreamerOnline( + ctx context.Context, + streamerID int, +) error { + args := pgx.NamedArgs{ + "streamer_id": streamerID, + } + err := repoDonat.db.Exec(ctx, sql.InsertStreamerOnline, args) + + if err != nil { + return err + } + return nil +} + +func (repoDonat *RepoDonat) GetLastStreamerOnline( + ctx context.Context, + streamerID int, +) (time.Time, error) { + args := pgx.NamedArgs{ + "streamer_id": streamerID, + } + row := repoDonat.db.SelectOne(ctx, sql.GetLastStreamerOnline, args) + + var StreamerOnline time.Time + + err := row.Scan(&StreamerOnline) + if err != nil { + return StreamerOnline, err + } + return StreamerOnline, nil +} diff --git a/internal/service/donat/donat.go b/internal/service/donat/donat.go index ff60c25..9022d42 100644 --- a/internal/service/donat/donat.go +++ b/internal/service/donat/donat.go @@ -14,6 +14,7 @@ import ( "regexp" "strconv" "strings" + "time" ) type ServiceDonat struct { @@ -343,10 +344,18 @@ func (donatService *ServiceDonat) GetOuterDonatPage( } } + err = donatService.getLastStreamerOnline(ctx, donatePage.StreamerID) + var online string + if err != nil { + online = "offline" + } else { + online = "online" + } + var outerDonatePageResponse = model.OuterDonatePageResponse{ Description: donatePage.Description, Login: donatePage.StreamerLogin, - OnLine: "online", + OnLine: online, BackgroundImg: donatService.storage.DownloadLink(donatePage.BackgroundImgFileId), HeadImg: donatService.storage.DownloadLink(donatePage.HeadImgFileId), AvatarImg: donatService.storage.DownloadLink(avatarFileId), @@ -856,6 +865,46 @@ func (donatService *ServiceDonat) replaceFilteredWords(text string, words []stri }) } +func (donatService *ServiceDonat) UpdateStreamerOnline( + ctx context.Context, + streamerID int, +) error { + err := donatService.donatRepo.CreateStreamerOnline( + ctx, + streamerID, + ) + if err != nil { + return err + } + return nil +} + +func (donatService *ServiceDonat) getLastStreamerOnline( + ctx context.Context, + streamerID int, +) error { + onlineTime, err := donatService.donatRepo.GetLastStreamerOnline( + ctx, + streamerID, + ) + if err != nil { + return err + } + + currentTime := time.Now().UTC() + diff := currentTime.Sub(onlineTime) + + if diff < 0 { + diff = -diff + } + + if diff > time.Minute { + return fmt.Errorf("last online time is more than a minute old") + } + + return nil +} + func (donatService *ServiceDonat) replaceLinks(text string) string { re := regexp.MustCompile(`(?i)\bhttps?://\S+\b`) return re.ReplaceAllStringFunc(text, func(match string) string {