Files
stream.api/internal/modules/common/helpers.go
2026-03-26 13:02:43 +00:00

754 lines
19 KiB
Go

package common
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
videodomain "stream.api/internal/video/runtime/domain"
runtimeservices "stream.api/internal/video/runtime/services"
)
type APIErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func AdminPageLimitOffset(pageValue int32, limitValue int32) (int32, int32, int) {
page := pageValue
if page < 1 {
page = 1
}
limit := limitValue
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := int((page - 1) * limit)
return page, limit, offset
}
func BuildAdminJob(job *videodomain.Job) *appv1.AdminJob {
if job == nil {
return nil
}
return &appv1.AdminJob{
Id: job.ID,
Status: string(job.Status),
Priority: int32(job.Priority),
UserId: job.UserID,
Name: job.Name,
TimeLimit: job.TimeLimit,
InputUrl: job.InputURL,
OutputUrl: job.OutputURL,
TotalDuration: job.TotalDuration,
CurrentTime: job.CurrentTime,
Progress: job.Progress,
AgentId: job.AgentID,
Logs: job.Logs,
Config: job.Config,
Cancelled: job.Cancelled,
RetryCount: int32(job.RetryCount),
MaxRetries: int32(job.MaxRetries),
CreatedAt: timestamppb.New(job.CreatedAt),
UpdatedAt: timestamppb.New(job.UpdatedAt),
VideoId: StringPointerOrNil(job.VideoID),
}
}
func BuildAdminAgent(agent *runtimeservices.AgentWithStats) *appv1.AdminAgent {
if agent == nil || agent.Agent == nil {
return nil
}
return &appv1.AdminAgent{
Id: agent.ID,
Name: agent.Name,
Platform: agent.Platform,
Backend: agent.Backend,
Version: agent.Version,
Capacity: agent.Capacity,
Status: string(agent.Status),
Cpu: agent.CPU,
Ram: agent.RAM,
LastHeartbeat: timestamppb.New(agent.LastHeartbeat),
CreatedAt: timestamppb.New(agent.CreatedAt),
UpdatedAt: timestamppb.New(agent.UpdatedAt),
}
}
func NormalizeAdminRoleValue(value string) string {
role := strings.ToUpper(strings.TrimSpace(value))
if role == "" {
return "USER"
}
return role
}
func IsValidAdminRoleValue(role string) bool {
switch NormalizeAdminRoleValue(role) {
case "USER", "ADMIN", "BLOCK":
return true
default:
return false
}
}
func ReferralUserEligible(user *model.User) bool {
if user == nil || user.ReferralEligible == nil {
return true
}
return *user.ReferralEligible
}
func EffectiveReferralRewardBps(value *int32) int32 {
if value == nil {
return DefaultReferralRewardBps
}
if *value < 0 {
return 0
}
if *value > 10000 {
return 10000
}
return *value
}
func ReferralRewardBpsToPercent(value int32) float64 {
return float64(value) / 100
}
func ReferralRewardProcessed(user *model.User) bool {
if user == nil {
return false
}
if user.ReferralRewardGrantedAt != nil {
return true
}
if user.ReferralRewardPaymentID != nil && strings.TrimSpace(*user.ReferralRewardPaymentID) != "" {
return true
}
return false
}
func BoolValue(value *bool) bool {
return value != nil && *value
}
func StringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func NullableTrimmedStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func NullableTrimmedString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func Int32PtrToInt64Ptr(value *int32) *int64 {
if value == nil {
return nil
}
converted := int64(*value)
return &converted
}
func Int64PtrToInt32Ptr(value *int64) *int32 {
if value == nil {
return nil
}
converted := int32(*value)
return &converted
}
func Int32Ptr(value int32) *int32 {
return &value
}
func ProtoStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func SafeRole(role *string) string {
if role == nil || strings.TrimSpace(*role) == "" {
return "USER"
}
return *role
}
func StringPointerOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func TimeToProto(value *time.Time) *timestamppb.Timestamp {
if value == nil {
return nil
}
return timestamppb.New(value.UTC())
}
func MaxFloat(left, right float64) float64 {
if left > right {
return left
}
return right
}
func FormatOptionalTimestamp(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func MustMarshalJSON(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
func NormalizeNotificationType(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
switch {
case strings.Contains(lower, "video"):
return "video"
case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"):
return "payment"
case strings.Contains(lower, "warning"):
return "warning"
case strings.Contains(lower, "error"):
return "error"
case strings.Contains(lower, "success"):
return "success"
case strings.Contains(lower, "system"):
return "system"
default:
return "info"
}
}
func NormalizeDomain(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
normalized = strings.TrimSuffix(normalized, "/")
return normalized
}
func NormalizeAdFormat(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "mid-roll", "post-roll":
return strings.TrimSpace(strings.ToLower(value))
default:
return "pre-roll"
}
}
func AdTemplateIsActive(value *bool) bool {
return value == nil || *value
}
func PlayerConfigIsActive(value *bool) bool {
return value == nil || *value
}
func UnsetDefaultTemplates(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.AdTemplate{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func UnsetDefaultPlayerConfigs(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func NormalizePaymentStatus(statusValue *string) string {
value := strings.ToLower(strings.TrimSpace(StringValue(statusValue)))
switch value {
case "success", "succeeded", "paid":
return "success"
case "failed", "error", "canceled", "cancelled":
return "failed"
case "pending", "processing":
return "pending"
default:
if value == "" {
return "success"
}
return value
}
}
func NormalizeCurrency(currency *string) string {
value := strings.ToUpper(strings.TrimSpace(StringValue(currency)))
if value == "" {
return "USD"
}
return value
}
func NormalizePaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case PaymentMethodWallet:
return PaymentMethodWallet
case PaymentMethodTopup:
return PaymentMethodTopup
default:
return ""
}
}
func NormalizeOptionalPaymentMethod(value *string) *string {
normalized := NormalizePaymentMethod(StringValue(value))
if normalized == "" {
return nil
}
return &normalized
}
func BuildInvoiceID(id string) string {
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
if len(trimmed) > 12 {
trimmed = trimmed[:12]
}
return "INV-" + strings.ToUpper(trimmed)
}
func BuildTransactionID(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
func BuildInvoiceFilename(id string) string {
return fmt.Sprintf("invoice-%s.txt", id)
}
func BuildTopupInvoice(transaction *model.WalletTransaction) string {
createdAt := FormatOptionalTimestamp(transaction.CreatedAt)
return strings.Join([]string{
"Stream API Wallet Top-up Invoice",
fmt.Sprintf("Invoice ID: %s", BuildInvoiceID(transaction.ID)),
fmt.Sprintf("Wallet Transaction ID: %s", transaction.ID),
fmt.Sprintf("User ID: %s", transaction.UserID),
fmt.Sprintf("Amount: %.2f %s", transaction.Amount, NormalizeCurrency(transaction.Currency)),
"Status: SUCCESS",
fmt.Sprintf("Type: %s", strings.ToUpper(transaction.Type)),
fmt.Sprintf("Note: %s", model.StringValue(transaction.Note)),
fmt.Sprintf("Created At: %s", createdAt),
}, "\n")
}
func StatusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int, message string, data any) error {
body := APIErrorBody{Code: httpCode, Message: message, Data: data}
encoded, err := json.Marshal(body)
if err == nil {
_ = grpc.SetTrailer(ctx, metadata.Pairs("x-error-body", string(encoded)))
}
return status.Error(grpcCode, message)
}
func IsAllowedTermMonths(value int32) bool {
_, ok := AllowedTermMonths[value]
return ok
}
func LockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", userID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func MessageResponse(message string) *appv1.MessageResponse {
return &appv1.MessageResponse{Message: message}
}
func EnsurePaidPlan(user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
return status.Error(codes.PermissionDenied, AdTemplateUpgradeRequiredMessage)
}
return nil
}
func PlayerConfigActionAllowed(user *model.User, configCount int64, action string) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" {
return nil
}
switch action {
case "create":
if configCount > 0 {
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanLimitMessage)
}
case "update", "set-default", "toggle-active":
if configCount > 1 {
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanReconciliationMessage)
}
}
return nil
}
func GenerateOAuthState() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func GoogleOAuthStateCacheKey(state string) string {
return "google_oauth_state:" + state
}
func ToProtoVideo(item *model.Video, jobID ...string) *appv1.Video {
if item == nil {
return nil
}
statusValue := StringValue(item.Status)
if statusValue == "" {
statusValue = "ready"
}
var linkedJobID *string
if len(jobID) > 0 {
linkedJobID = StringPointerOrNil(jobID[0])
}
return &appv1.Video{
Id: item.ID,
UserId: item.UserID,
Title: item.Title,
Description: item.Description,
Url: item.URL,
Status: strings.ToLower(statusValue),
Size: item.Size,
Duration: item.Duration,
Format: item.Format,
Thumbnail: item.Thumbnail,
ProcessingStatus: item.ProcessingStatus,
StorageType: item.StorageType,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
JobId: linkedJobID,
}
}
func NormalizeVideoStatusValue(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "processing", "pending":
return "processing"
case "failed", "error":
return "failed"
default:
return "ready"
}
}
func DetectStorageType(rawURL string) string {
if ShouldDeleteStoredObject(rawURL) {
return "S3"
}
return "WORKER"
}
func ShouldDeleteStoredObject(rawURL string) bool {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return !strings.HasPrefix(trimmed, "/")
}
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
}
func ExtractObjectKey(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return trimmed
}
return strings.TrimPrefix(parsed.Path, "/")
}
func ToProtoUser(user *UserPayload) *appv1.User {
if user == nil {
return nil
}
return &appv1.User{
Id: user.ID,
Email: user.Email,
Username: user.Username,
Avatar: user.Avatar,
Role: user.Role,
GoogleId: user.GoogleID,
StorageUsed: user.StorageUsed,
PlanId: user.PlanID,
PlanStartedAt: TimeToProto(user.PlanStartedAt),
PlanExpiresAt: TimeToProto(user.PlanExpiresAt),
PlanTermMonths: user.PlanTermMonths,
PlanPaymentMethod: user.PlanPaymentMethod,
PlanExpiringSoon: user.PlanExpiringSoon,
WalletBalance: user.WalletBalance,
Language: user.Language,
Locale: user.Locale,
CreatedAt: TimeToProto(user.CreatedAt),
UpdatedAt: timestamppb.New(user.UpdatedAt),
}
}
func ToProtoPreferences(pref *model.UserPreference) *appv1.Preferences {
if pref == nil {
return nil
}
return &appv1.Preferences{
EmailNotifications: BoolValue(pref.EmailNotifications),
PushNotifications: BoolValue(pref.PushNotifications),
MarketingNotifications: pref.MarketingNotifications,
TelegramNotifications: pref.TelegramNotifications,
Language: model.StringValue(pref.Language),
Locale: model.StringValue(pref.Locale),
}
}
func ToProtoNotification(item model.Notification) *appv1.Notification {
return &appv1.Notification{
Id: item.ID,
Type: NormalizeNotificationType(item.Type),
Title: item.Title,
Message: item.Message,
Read: item.IsRead,
ActionUrl: item.ActionURL,
ActionLabel: item.ActionLabel,
CreatedAt: TimeToProto(item.CreatedAt),
}
}
func ToProtoDomain(item *model.Domain) *appv1.Domain {
if item == nil {
return nil
}
return &appv1.Domain{
Id: item.ID,
Name: item.Name,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate {
if item == nil {
return nil
}
return &appv1.AdTemplate{
Id: item.ID,
Name: item.Name,
Description: item.Description,
VastTagUrl: item.VastTagURL,
AdFormat: model.StringValue(item.AdFormat),
Duration: Int64PtrToInt32Ptr(item.Duration),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig {
if item == nil {
return nil
}
return &appv1.PlayerConfig{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: BoolValue(item.ShowControls),
Pip: BoolValue(item.Pip),
Airplay: BoolValue(item.Airplay),
Chromecast: BoolValue(item.Chromecast),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(&item.UpdatedAt),
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
LogoUrl: NullableTrimmedString(item.LogoURL),
}
}
func ToProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig {
if item == nil {
return nil
}
return &appv1.AdminPlayerConfig{
Id: item.ID,
UserId: item.UserID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: BoolValue(item.ShowControls),
Pip: BoolValue(item.Pip),
Airplay: BoolValue(item.Airplay),
Chromecast: BoolValue(item.Chromecast),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
OwnerEmail: ownerEmail,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(&item.UpdatedAt),
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
LogoUrl: NullableTrimmedString(item.LogoURL),
}
}
func ToProtoPlan(item *model.Plan) *appv1.Plan {
if item == nil {
return nil
}
return &appv1.Plan{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Price: item.Price,
Cycle: item.Cycle,
StorageLimit: item.StorageLimit,
UploadLimit: item.UploadLimit,
DurationLimit: item.DurationLimit,
QualityLimit: item.QualityLimit,
Features: item.Features,
IsActive: BoolValue(item.IsActive),
}
}
func ToProtoPayment(item *model.Payment) *appv1.Payment {
if item == nil {
return nil
}
return &appv1.Payment{
Id: item.ID,
UserId: item.UserID,
PlanId: item.PlanID,
Amount: item.Amount,
Currency: NormalizeCurrency(item.Currency),
Status: NormalizePaymentStatus(item.Status),
Provider: strings.ToUpper(StringValue(item.Provider)),
TransactionId: item.TransactionID,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
}
}
func ToProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription {
if item == nil {
return nil
}
return &appv1.PlanSubscription{
Id: item.ID,
UserId: item.UserID,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
PaymentMethod: item.PaymentMethod,
WalletAmount: item.WalletAmount,
TopupAmount: item.TopupAmount,
StartedAt: timestamppb.New(item.StartedAt.UTC()),
ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()),
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction {
if item == nil {
return nil
}
return &appv1.WalletTransaction{
Id: item.ID,
UserId: item.UserID,
Type: item.Type,
Amount: item.Amount,
Currency: NormalizeCurrency(item.Currency),
Note: item.Note,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}