add routers and logic for online donat

This commit is contained in:
harold 2025-05-15 10:16:02 +05:00
parent a4cbb43ccd
commit ae5301a222
11 changed files with 286 additions and 27 deletions

View File

@ -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 // UpdateLoginDonatePage godoc
// @Summary Update streamer login // @Summary Update streamer login
// @Description Updates the streamer login associated with the donate page // @Description Updates the streamer login associated with the donate page

View File

@ -107,6 +107,7 @@ func IncludeDonatHandlers(
server.GET(PREFIX+"/get-donat-for-playing/:streamer-id", GetDonatForPlaying(donatService)) server.GET(PREFIX+"/get-donat-for-playing/:streamer-id", GetDonatForPlaying(donatService))
server.GET(PREFIX+"/text-after-donat/:order-id", GetMessageAfterDonat(donatService)) server.GET(PREFIX+"/text-after-donat/:order-id", GetMessageAfterDonat(donatService))
server.POST(PREFIX+"/update-login-donate", UpdateLoginDonatePage(donatService)) server.POST(PREFIX+"/update-login-donate", UpdateLoginDonatePage(donatService))
server.PUT(PREFIX+"/update-streamer-online/:streamer-id", UpdateStreamerOnline(donatService))
} }
func IncludeWidgetHandlers( func IncludeWidgetHandlers(

View File

@ -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": { "/voice-settings": {
"get": { "get": {
"security": [ "security": [

View File

@ -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": { "/voice-settings": {
"get": { "get": {
"security": [ "security": [

View File

@ -1328,6 +1328,36 @@ paths:
summary: Update streamer login summary: Update streamer login
tags: tags:
- Donate - 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: /voice-settings:
get: get:
consumes: consumes:

View File

@ -4,6 +4,7 @@ import (
"donat-widget/internal/model/api" "donat-widget/internal/model/api"
"github.com/google/uuid" "github.com/google/uuid"
"mime/multipart" "mime/multipart"
"time"
) )
import ( import (
@ -108,6 +109,7 @@ type DonatService interface {
UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error
UpdateAvatarStreamer(token, avatarId string) error UpdateAvatarStreamer(token, avatarId string) error
GetTextAfterDonatByOrder(ctx context.Context, orderID uuid.UUID) (string, error) GetTextAfterDonatByOrder(ctx context.Context, orderID uuid.UUID) (string, error)
UpdateStreamerOnline(ctx context.Context, streamerID int) error
} }
type DonatRepo interface { type DonatRepo interface {
@ -178,6 +180,8 @@ type DonatRepo interface {
GetPlayingDonat(ctx context.Context, streamerID int) (PlayingDonat, error) GetPlayingDonat(ctx context.Context, streamerID int) (PlayingDonat, error)
UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) 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 { type TargetService interface {

View File

@ -130,6 +130,12 @@ CREATE TABLE IF NOT EXISTS streamers_widgets_pages (
id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
streamer_id INTEGER NOT NULL 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() CREATE OR REPLACE FUNCTION update_updated_at()

View File

@ -589,3 +589,9 @@ SELECT streamer_id FROM streamers_widgets_pages WHERE id = @widget_page_id`
var UpdateStreamerLogin = ` var UpdateStreamerLogin = `
UPDATE donate_pages SET streamer_login = @streamer_login WHERE streamer_id = @streamer_id UPDATE donate_pages SET streamer_login = @streamer_login WHERE streamer_id = @streamer_id
RETURNING streamer_login;` 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`

View File

@ -64,6 +64,20 @@ func GetTemplate1(streamerID int, donatHost, ttsHost string) string {
let widgetUrl = 'https://%s/api'; let widgetUrl = 'https://%s/api';
let ttsUrl = 'https://%s/api/tts'; 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) { function createTextWithAmount(text, amount) {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'text-container'; container.className = 'text-container';
@ -120,7 +134,7 @@ function playAudio(url, volume, signal) {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
audio.pause(); audio.pause();
audio.src = ""; // Release resources audio.src = "";
audio.removeEventListener('ended', onEnded); audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError); audio.removeEventListener('error', onError);
reject(new DOMException('Aborted', 'AbortError')); reject(new DOMException('Aborted', 'AbortError'));
@ -148,16 +162,15 @@ function playAudio(url, volume, signal) {
audio.addEventListener('error', onError, { once: true }); audio.addEventListener('error', onError, { once: true });
audio.play().catch(err => { audio.play().catch(err => {
// play() can reject if interrupted or no user gesture
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
signal?.removeEventListener('abort', onAbort); signal?.removeEventListener('abort', onAbort);
audio.removeEventListener('ended', onEnded); audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError); audio.removeEventListener('error', onError);
if (err.name === 'AbortError' && signal?.aborted) { if (err.name === 'AbortError' && signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError')); // Propagate abort reject(new DOMException('Aborted', 'AbortError'));
} else { } else {
reject(err); // Other play() error reject(err);
} }
}); });
}); });
@ -208,7 +221,7 @@ function playSpeech(text, voiceSettings, signal) {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal: signal // Pass signal to fetch signal: signal
}) })
.then(response => { .then(response => {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
@ -241,10 +254,9 @@ function playSpeech(text, voiceSettings, signal) {
return audio.play(); return audio.play();
}) })
.catch(error => { .catch(error => {
// This catches errors from fetch, blob processing, or audio.play() if (resolved) return;
if (resolved) return; // Already handled signal?.removeEventListener('abort', onAbort);
signal?.removeEventListener('abort', onAbort); // Ensure listener removed cleanupAndReject(error);
cleanupAndReject(error); // Pass the original error
}); });
}); });
} }
@ -273,26 +285,24 @@ async function playMedia(donat, voiceSettings) {
ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal); ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal);
} }
} catch (error) { } catch (error) {
if (error.name !== 'AbortError') { // Only re-throw non-abort errors if (error.name !== 'AbortError') {
console.error('Error during media playback:', error); console.error('Error during media playback:', error);
throw error; throw error;
} }
// If AbortError, it's handled by the timeout logic or external abort.
} }
})(); })();
const timeoutPromise = new Promise((resolve, reject) => { const timeoutPromise = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
controller.abort(); // Signal all operations to stop controller.abort();
resolve('timeout'); // Resolve to indicate timeout completion resolve('timeout');
}, (donat.duration || 0) * 1000); // Use donat.duration, default to 0 if undefined }, (donat.duration || 0) * 1000);
}); });
try { try {
await Promise.race([mediaOperation, timeoutPromise]); await Promise.race([mediaOperation, timeoutPromise]);
} finally { } finally {
clearTimeout(timeoutId); // Crucial: clear timeout if mediaOperation finished first or errored clearTimeout(timeoutId);
// Ensure audio elements are stopped and reset if they were initiated and not properly cleaned up by abort
if (audioElement && !audioElement.paused) { if (audioElement && !audioElement.paused) {
audioElement.pause(); audioElement.pause();
audioElement.currentTime = 0; audioElement.currentTime = 0;
@ -312,6 +322,8 @@ function clearContainer(container) {
async function widgetView() { async function widgetView() {
const streamerID = '%v'; const streamerID = '%v';
startHeartbeat(streamerID); // Запускаем heartbeat
const contentDiv = document.getElementById('content'); const contentDiv = document.getElementById('content');
if (!contentDiv) { if (!contentDiv) {
@ -321,15 +333,15 @@ async function widgetView() {
while (true) { while (true) {
const iterationStart = Date.now(); const iterationStart = Date.now();
let currentDonat = null; // Store current donat for duration calculation let currentDonat = null;
try { try {
const donatData = await getDonatInfo(streamerID); const donatData = await getDonatInfo(streamerID);
if (!donatData || Object.keys(donatData).length === 0) { 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; continue;
} }
currentDonat = donatData; // Assign to currentDonat currentDonat = donatData;
clearContainer(contentDiv); clearContainer(contentDiv);
@ -363,7 +375,6 @@ async function widgetView() {
voice_enabled: currentDonat.voice_enabled voice_enabled: currentDonat.voice_enabled
}; };
// playMedia will ensure audio plays for at most currentDonat.duration
await playMedia(currentDonat, voiceSettings); await playMedia(currentDonat, voiceSettings);
if (currentDonat.order_id) { if (currentDonat.order_id) {
@ -379,18 +390,14 @@ async function widgetView() {
} }
} catch (error) { } catch (error) {
// Log errors from getDonatInfo, playMedia, or other processing
console.error('Ошибка обработки доната в цикле:', error); console.error('Ошибка обработки доната в цикле:', error);
// If error, maybe wait a bit before next iteration to avoid spamming if (!currentDonat) {
if (!currentDonat) { // e.g. error in getDonatInfo
await new Promise(r => setTimeout(r, 5000)); await new Promise(r => setTimeout(r, 5000));
continue; continue;
} }
} }
const elapsedMs = Date.now() - iterationStart; 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 targetDisplayTimeMs = (currentDonat?.duration || 5) * 1000;
const remainingTimeMs = Math.max(0, targetDisplayTimeMs - elapsedMs); const remainingTimeMs = Math.max(0, targetDisplayTimeMs - elapsedMs);
@ -398,7 +405,6 @@ async function widgetView() {
if (remainingTimeMs > 0) { if (remainingTimeMs > 0) {
await new Promise(r => setTimeout(r, remainingTimeMs)); await new Promise(r => setTimeout(r, remainingTimeMs));
} }
// If elapsedMs already exceeds targetDisplayTimeMs, loop will continue immediately
} }
} }

View File

@ -881,3 +881,36 @@ func (repoDonat *RepoDonat) UpdateStreamerLogin(
} }
return nil 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
}

View File

@ -14,6 +14,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type ServiceDonat struct { 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{ var outerDonatePageResponse = model.OuterDonatePageResponse{
Description: donatePage.Description, Description: donatePage.Description,
Login: donatePage.StreamerLogin, Login: donatePage.StreamerLogin,
OnLine: "online", OnLine: online,
BackgroundImg: donatService.storage.DownloadLink(donatePage.BackgroundImgFileId), BackgroundImg: donatService.storage.DownloadLink(donatePage.BackgroundImgFileId),
HeadImg: donatService.storage.DownloadLink(donatePage.HeadImgFileId), HeadImg: donatService.storage.DownloadLink(donatePage.HeadImgFileId),
AvatarImg: donatService.storage.DownloadLink(avatarFileId), 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 { func (donatService *ServiceDonat) replaceLinks(text string) string {
re := regexp.MustCompile(`(?i)\bhttps?://\S+\b`) re := regexp.MustCompile(`(?i)\bhttps?://\S+\b`)
return re.ReplaceAllStringFunc(text, func(match string) string { return re.ReplaceAllStringFunc(text, func(match string) string {