Implement CreatorAnalyticsService with: - GetCreatorDashboard: aggregated plays, listeners, revenue (F381) - GetPlayEvolution: temporal data by day/week/month (F382) - GetSalesSummary: revenue and sales history (F383) - GetDiscoverySources: how listeners find tracks (F381) - GetGeographicBreakdown: anonymized geographic data (F381) - GetAudienceProfile: aggregated audience demographics, min 10 users (F384) - GetLiveStreamMetrics: real-time viewer count (F385) - GetPerTrackStats: per-track analytics with pagination All data is private to the creator, never exposed publicly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
601 lines
21 KiB
Go
601 lines
21 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// CreatorAnalyticsService provides analytics data for creators (F381-F395)
|
|
// All data is private to the creator — never exposed publicly.
|
|
type CreatorAnalyticsService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewCreatorAnalyticsService creates a new creator analytics service
|
|
func NewCreatorAnalyticsService(db *gorm.DB, logger *zap.Logger) *CreatorAnalyticsService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &CreatorAnalyticsService{db: db, logger: logger}
|
|
}
|
|
|
|
// CreatorDashboardStats is the aggregated creator dashboard response (F381)
|
|
type CreatorDashboardStats struct {
|
|
TotalPlays int64 `json:"total_plays"`
|
|
CompleteListens int64 `json:"complete_listens"`
|
|
UniqueListeners int64 `json:"unique_listeners"`
|
|
TotalPlayTime int64 `json:"total_play_time"` // seconds
|
|
AvgPlayDuration float64 `json:"avg_play_duration"` // seconds
|
|
AvgCompletionRate float64 `json:"avg_completion_rate"` // percentage
|
|
TotalTracks int64 `json:"total_tracks"`
|
|
TotalFollowers int64 `json:"total_followers"`
|
|
TotalRevenue float64 `json:"total_revenue"`
|
|
}
|
|
|
|
// GetCreatorDashboard returns aggregated stats for a creator's dashboard (F381)
|
|
func (s *CreatorAnalyticsService) GetCreatorDashboard(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*CreatorDashboardStats, error) {
|
|
stats := &CreatorDashboardStats{}
|
|
|
|
// Plays, unique listeners, complete listens, play time, completion from playback_analytics
|
|
var playbackAgg struct {
|
|
TotalPlays int64 `gorm:"column:total_plays"`
|
|
UniqueListeners int64 `gorm:"column:unique_listeners"`
|
|
CompleteListens int64 `gorm:"column:complete_listens"`
|
|
TotalPlayTime int64 `gorm:"column:total_play_time"`
|
|
AvgCompletion float64 `gorm:"column:avg_completion"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT
|
|
COUNT(pa.id) AS total_plays,
|
|
COUNT(DISTINCT pa.user_id) AS unique_listeners,
|
|
COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens,
|
|
COALESCE(SUM(pa.play_time), 0) AS total_play_time,
|
|
COALESCE(AVG(pa.completion_rate), 0) AS avg_completion
|
|
FROM playback_analytics pa
|
|
JOIN tracks t ON t.id = pa.track_id
|
|
WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?
|
|
`, creatorID, startDate, endDate).Scan(&playbackAgg).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get playback stats: %w", err)
|
|
}
|
|
|
|
stats.TotalPlays = playbackAgg.TotalPlays
|
|
stats.UniqueListeners = playbackAgg.UniqueListeners
|
|
stats.CompleteListens = playbackAgg.CompleteListens
|
|
stats.TotalPlayTime = playbackAgg.TotalPlayTime
|
|
stats.AvgCompletionRate = playbackAgg.AvgCompletion
|
|
if stats.TotalPlays > 0 {
|
|
stats.AvgPlayDuration = float64(stats.TotalPlayTime) / float64(stats.TotalPlays)
|
|
}
|
|
|
|
// Also add track_plays data
|
|
var trackPlayAgg struct {
|
|
TrackPlays int64 `gorm:"column:track_plays"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COUNT(tp.id) AS track_plays
|
|
FROM track_plays tp
|
|
JOIN tracks t ON t.id = tp.track_id
|
|
WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?
|
|
`, creatorID, startDate, endDate).Scan(&trackPlayAgg).Error; err != nil {
|
|
s.logger.Warn("Failed to get track_plays count", zap.Error(err))
|
|
}
|
|
// Use the higher of the two counts
|
|
if trackPlayAgg.TrackPlays > stats.TotalPlays {
|
|
stats.TotalPlays = trackPlayAgg.TrackPlays
|
|
}
|
|
|
|
// Total tracks
|
|
s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", creatorID).Count(&stats.TotalTracks)
|
|
|
|
// Total followers
|
|
s.db.WithContext(ctx).Table("follows").Where("followed_id = ?", creatorID).Count(&stats.TotalFollowers)
|
|
|
|
// Total revenue
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COALESCE(SUM(oi.price), 0)
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
WHERE p.seller_id = ? AND o.status IN ('completed', 'paid')
|
|
AND o.created_at >= ? AND o.created_at <= ?
|
|
`, creatorID, startDate, endDate).Scan(&stats.TotalRevenue).Error; err != nil {
|
|
s.logger.Warn("Failed to get revenue", zap.Error(err))
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// PlayEvolutionPoint represents a single data point in the play evolution chart (F382)
|
|
type PlayEvolutionPoint struct {
|
|
Date string `json:"date"`
|
|
TotalPlays int64 `json:"total_plays"`
|
|
CompleteListens int64 `json:"complete_listens"`
|
|
UniqueListeners int64 `json:"unique_listeners"`
|
|
TotalPlayTime int64 `json:"total_play_time"`
|
|
AvgCompletion float64 `json:"avg_completion"`
|
|
}
|
|
|
|
// GetPlayEvolution returns play data over time for the creator (F382)
|
|
// interval: "day", "week", "month"
|
|
func (s *CreatorAnalyticsService) GetPlayEvolution(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time, interval string) ([]PlayEvolutionPoint, error) {
|
|
var dateExpr string
|
|
switch interval {
|
|
case "week":
|
|
dateExpr = "DATE_TRUNC('week', pa.started_at)::date"
|
|
case "month":
|
|
dateExpr = "DATE_TRUNC('month', pa.started_at)::date"
|
|
default:
|
|
dateExpr = "DATE(pa.started_at)"
|
|
}
|
|
|
|
var rows []struct {
|
|
Date string `gorm:"column:date"`
|
|
TotalPlays int64 `gorm:"column:total_plays"`
|
|
CompleteListens int64 `gorm:"column:complete_listens"`
|
|
UniqueListeners int64 `gorm:"column:unique_listeners"`
|
|
TotalPlayTime int64 `gorm:"column:total_play_time"`
|
|
AvgCompletion float64 `gorm:"column:avg_completion"`
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
%s AS date,
|
|
COUNT(pa.id) AS total_plays,
|
|
COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens,
|
|
COUNT(DISTINCT pa.user_id) AS unique_listeners,
|
|
COALESCE(SUM(pa.play_time), 0) AS total_play_time,
|
|
COALESCE(AVG(pa.completion_rate), 0) AS avg_completion
|
|
FROM playback_analytics pa
|
|
JOIN tracks t ON t.id = pa.track_id
|
|
WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?
|
|
GROUP BY date
|
|
ORDER BY date ASC
|
|
`, dateExpr)
|
|
|
|
if err := s.db.WithContext(ctx).Raw(query, creatorID, startDate, endDate).Scan(&rows).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get play evolution: %w", err)
|
|
}
|
|
|
|
points := make([]PlayEvolutionPoint, len(rows))
|
|
for i, r := range rows {
|
|
points[i] = PlayEvolutionPoint{
|
|
Date: r.Date,
|
|
TotalPlays: r.TotalPlays,
|
|
CompleteListens: r.CompleteListens,
|
|
UniqueListeners: r.UniqueListeners,
|
|
TotalPlayTime: r.TotalPlayTime,
|
|
AvgCompletion: r.AvgCompletion,
|
|
}
|
|
}
|
|
return points, nil
|
|
}
|
|
|
|
// SalesRecord represents a sales entry for downloads and purchases (F383)
|
|
type SalesRecord struct {
|
|
Date string `json:"date"`
|
|
TrackID string `json:"track_id"`
|
|
TrackTitle string `json:"track_title"`
|
|
ProductType string `json:"product_type"`
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
BuyerCount int64 `json:"buyer_count"`
|
|
}
|
|
|
|
// SalesSummary aggregates sales data (F383)
|
|
type SalesSummary struct {
|
|
TotalRevenue float64 `json:"total_revenue"`
|
|
TotalSales int64 `json:"total_sales"`
|
|
RevenueByPeriod []RevenuePeriod `json:"revenue_by_period"`
|
|
TopSellingTracks []TopSellingTrack `json:"top_selling_tracks"`
|
|
}
|
|
|
|
// RevenuePeriod represents revenue for a time period
|
|
type RevenuePeriod struct {
|
|
Date string `json:"date"`
|
|
Revenue float64 `json:"revenue"`
|
|
Sales int64 `json:"sales"`
|
|
}
|
|
|
|
// TopSellingTrack represents a top-selling track
|
|
type TopSellingTrack struct {
|
|
TrackID string `json:"track_id"`
|
|
Title string `json:"title"`
|
|
Revenue float64 `json:"revenue"`
|
|
Sales int64 `json:"sales"`
|
|
}
|
|
|
|
// GetSalesSummary returns sales and download data for the creator (F383)
|
|
func (s *CreatorAnalyticsService) GetSalesSummary(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*SalesSummary, error) {
|
|
summary := &SalesSummary{
|
|
RevenueByPeriod: []RevenuePeriod{},
|
|
TopSellingTracks: []TopSellingTrack{},
|
|
}
|
|
|
|
// Total revenue and sales count
|
|
var totals struct {
|
|
TotalRevenue float64 `gorm:"column:total_revenue"`
|
|
TotalSales int64 `gorm:"column:total_sales"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COALESCE(SUM(oi.price), 0) AS total_revenue, COUNT(oi.id) AS total_sales
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
WHERE p.seller_id = ? AND o.status IN ('completed', 'paid')
|
|
AND o.created_at >= ? AND o.created_at <= ?
|
|
`, creatorID, startDate, endDate).Scan(&totals).Error; err != nil {
|
|
s.logger.Warn("Failed to get sales totals", zap.Error(err))
|
|
} else {
|
|
summary.TotalRevenue = totals.TotalRevenue
|
|
summary.TotalSales = totals.TotalSales
|
|
}
|
|
|
|
// Revenue by day
|
|
var periodRows []struct {
|
|
Date string `gorm:"column:date"`
|
|
Revenue float64 `gorm:"column:revenue"`
|
|
Sales int64 `gorm:"column:sales"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT DATE(o.created_at) AS date, COALESCE(SUM(oi.price), 0) AS revenue, COUNT(oi.id) AS sales
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
WHERE p.seller_id = ? AND o.status IN ('completed', 'paid')
|
|
AND o.created_at >= ? AND o.created_at <= ?
|
|
GROUP BY DATE(o.created_at)
|
|
ORDER BY date ASC
|
|
`, creatorID, startDate, endDate).Scan(&periodRows).Error; err != nil {
|
|
s.logger.Warn("Failed to get revenue by period", zap.Error(err))
|
|
}
|
|
for _, r := range periodRows {
|
|
summary.RevenueByPeriod = append(summary.RevenueByPeriod, RevenuePeriod{
|
|
Date: r.Date, Revenue: r.Revenue, Sales: r.Sales,
|
|
})
|
|
}
|
|
|
|
// Top selling tracks
|
|
var topRows []struct {
|
|
TrackID string `gorm:"column:track_id"`
|
|
Title string `gorm:"column:title"`
|
|
Revenue float64 `gorm:"column:revenue"`
|
|
Sales int64 `gorm:"column:sales"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT CAST(p.id AS TEXT) AS track_id, COALESCE(t.title, p.name) AS title,
|
|
COALESCE(SUM(oi.price), 0) AS revenue, COUNT(oi.id) AS sales
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
LEFT JOIN tracks t ON t.id = p.id
|
|
WHERE p.seller_id = ? AND o.status IN ('completed', 'paid')
|
|
AND o.created_at >= ? AND o.created_at <= ?
|
|
GROUP BY p.id, t.title, p.name
|
|
ORDER BY revenue DESC
|
|
LIMIT 10
|
|
`, creatorID, startDate, endDate).Scan(&topRows).Error; err != nil {
|
|
s.logger.Warn("Failed to get top selling tracks", zap.Error(err))
|
|
}
|
|
for _, r := range topRows {
|
|
summary.TopSellingTracks = append(summary.TopSellingTracks, TopSellingTrack{
|
|
TrackID: r.TrackID, Title: r.Title, Revenue: r.Revenue, Sales: r.Sales,
|
|
})
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// DiscoverySourceBreakdown represents how users discover the creator's tracks (F381)
|
|
type DiscoverySourceBreakdown struct {
|
|
Source string `json:"source"`
|
|
Count int64 `json:"count"`
|
|
Percentage float64 `json:"percentage"`
|
|
}
|
|
|
|
// GetDiscoverySources returns how listeners find the creator's tracks (F381)
|
|
func (s *CreatorAnalyticsService) GetDiscoverySources(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) ([]DiscoverySourceBreakdown, error) {
|
|
var rows []struct {
|
|
Source string `gorm:"column:source"`
|
|
Count int64 `gorm:"column:cnt"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COALESCE(NULLIF(tp.source, ''), 'direct') AS source, COUNT(*) AS cnt
|
|
FROM track_plays tp
|
|
JOIN tracks t ON t.id = tp.track_id
|
|
WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?
|
|
GROUP BY source
|
|
ORDER BY cnt DESC
|
|
`, creatorID, startDate, endDate).Scan(&rows).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get discovery sources: %w", err)
|
|
}
|
|
|
|
var total int64
|
|
for _, r := range rows {
|
|
total += r.Count
|
|
}
|
|
|
|
sources := make([]DiscoverySourceBreakdown, len(rows))
|
|
for i, r := range rows {
|
|
pct := float64(0)
|
|
if total > 0 {
|
|
pct = float64(r.Count) / float64(total) * 100
|
|
}
|
|
sources[i] = DiscoverySourceBreakdown{
|
|
Source: r.Source,
|
|
Count: r.Count,
|
|
Percentage: pct,
|
|
}
|
|
}
|
|
return sources, nil
|
|
}
|
|
|
|
// GeographicBreakdown represents geographic distribution of plays (F381)
|
|
type GeographicBreakdown struct {
|
|
CountryCode string `json:"country_code"`
|
|
Region string `json:"region,omitempty"`
|
|
PlayCount int64 `json:"play_count"`
|
|
UniqueListeners int64 `json:"unique_listeners"`
|
|
Percentage float64 `json:"percentage"`
|
|
}
|
|
|
|
// GetGeographicBreakdown returns geographic distribution of plays (F381)
|
|
// Data is always aggregated — never exposes individual user locations
|
|
func (s *CreatorAnalyticsService) GetGeographicBreakdown(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) ([]GeographicBreakdown, error) {
|
|
var rows []struct {
|
|
CountryCode string `gorm:"column:country_code"`
|
|
PlayCount int64 `gorm:"column:play_count"`
|
|
UniqueListeners int64 `gorm:"column:unique_listeners"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COALESCE(NULLIF(tp.country_code, ''), 'XX') AS country_code,
|
|
COUNT(*) AS play_count,
|
|
COUNT(DISTINCT tp.user_id) AS unique_listeners
|
|
FROM track_plays tp
|
|
JOIN tracks t ON t.id = tp.track_id
|
|
WHERE t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?
|
|
GROUP BY country_code
|
|
HAVING COUNT(DISTINCT tp.user_id) >= 10
|
|
ORDER BY play_count DESC
|
|
`, creatorID, startDate, endDate).Scan(&rows).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get geographic breakdown: %w", err)
|
|
}
|
|
|
|
var totalPlays int64
|
|
for _, r := range rows {
|
|
totalPlays += r.PlayCount
|
|
}
|
|
|
|
result := make([]GeographicBreakdown, len(rows))
|
|
for i, r := range rows {
|
|
pct := float64(0)
|
|
if totalPlays > 0 {
|
|
pct = float64(r.PlayCount) / float64(totalPlays) * 100
|
|
}
|
|
result[i] = GeographicBreakdown{
|
|
CountryCode: r.CountryCode,
|
|
PlayCount: r.PlayCount,
|
|
UniqueListeners: r.UniqueListeners,
|
|
Percentage: pct,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// AudienceProfile represents aggregated audience demographics (F384)
|
|
// Only shown when >= 10 unique listeners for privacy
|
|
type AudienceProfile struct {
|
|
TotalListeners int64 `json:"total_listeners"`
|
|
ListenersByGenre []GenreBreakdown `json:"listeners_by_genre"`
|
|
TopListeningTimes []ListeningTimeSlot `json:"top_listening_times"`
|
|
}
|
|
|
|
// GenreBreakdown shows what genres the audience listens to
|
|
type GenreBreakdown struct {
|
|
Genre string `json:"genre"`
|
|
Count int64 `json:"count"`
|
|
Percentage float64 `json:"percentage"`
|
|
}
|
|
|
|
// ListeningTimeSlot shows when the audience listens most
|
|
type ListeningTimeSlot struct {
|
|
Hour int `json:"hour"`
|
|
PlayCount int64 `json:"play_count"`
|
|
}
|
|
|
|
// GetAudienceProfile returns aggregated audience data (F384)
|
|
// Requires minimum 10 unique listeners for privacy protection
|
|
func (s *CreatorAnalyticsService) GetAudienceProfile(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time) (*AudienceProfile, error) {
|
|
profile := &AudienceProfile{
|
|
ListenersByGenre: []GenreBreakdown{},
|
|
TopListeningTimes: []ListeningTimeSlot{},
|
|
}
|
|
|
|
// Count unique listeners
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT COUNT(DISTINCT pa.user_id)
|
|
FROM playback_analytics pa
|
|
JOIN tracks t ON t.id = pa.track_id
|
|
WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?
|
|
`, creatorID, startDate, endDate).Scan(&profile.TotalListeners).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count listeners: %w", err)
|
|
}
|
|
|
|
// Privacy check: minimum 10 unique listeners
|
|
if profile.TotalListeners < 10 {
|
|
return profile, nil
|
|
}
|
|
|
|
// What genres does the audience also listen to
|
|
var genreRows []struct {
|
|
Genre string `gorm:"column:genre"`
|
|
Count int64 `gorm:"column:cnt"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT t2.genre, COUNT(DISTINCT pa2.id) AS cnt
|
|
FROM playback_analytics pa
|
|
JOIN tracks t ON t.id = pa.track_id
|
|
JOIN playback_analytics pa2 ON pa2.user_id = pa.user_id AND pa2.track_id != pa.track_id
|
|
JOIN tracks t2 ON t2.id = pa2.track_id
|
|
WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?
|
|
AND t2.genre != '' AND t2.genre IS NOT NULL
|
|
GROUP BY t2.genre
|
|
ORDER BY cnt DESC
|
|
LIMIT 10
|
|
`, creatorID, startDate, endDate).Scan(&genreRows).Error; err != nil {
|
|
s.logger.Warn("Failed to get audience genres", zap.Error(err))
|
|
}
|
|
|
|
var totalGenre int64
|
|
for _, r := range genreRows {
|
|
totalGenre += r.Count
|
|
}
|
|
for _, r := range genreRows {
|
|
pct := float64(0)
|
|
if totalGenre > 0 {
|
|
pct = float64(r.Count) / float64(totalGenre) * 100
|
|
}
|
|
profile.ListenersByGenre = append(profile.ListenersByGenre, GenreBreakdown{
|
|
Genre: r.Genre, Count: r.Count, Percentage: pct,
|
|
})
|
|
}
|
|
|
|
// Peak listening times (hour of day)
|
|
var timeRows []struct {
|
|
Hour int `gorm:"column:hour"`
|
|
PlayCount int64 `gorm:"column:play_count"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT EXTRACT(HOUR FROM pa.started_at)::int AS hour, COUNT(*) AS play_count
|
|
FROM playback_analytics pa
|
|
JOIN tracks t ON t.id = pa.track_id
|
|
WHERE t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?
|
|
GROUP BY hour
|
|
ORDER BY hour ASC
|
|
`, creatorID, startDate, endDate).Scan(&timeRows).Error; err != nil {
|
|
s.logger.Warn("Failed to get listening times", zap.Error(err))
|
|
}
|
|
for _, r := range timeRows {
|
|
profile.TopListeningTimes = append(profile.TopListeningTimes, ListeningTimeSlot{
|
|
Hour: r.Hour, PlayCount: r.PlayCount,
|
|
})
|
|
}
|
|
|
|
return profile, nil
|
|
}
|
|
|
|
// LiveStreamMetrics represents real-time metrics for a live stream (F385)
|
|
type LiveStreamMetrics struct {
|
|
StreamID string `json:"stream_id"`
|
|
CurrentViewers int `json:"current_viewers"`
|
|
PeakViewers int `json:"peak_viewers"`
|
|
IsLive bool `json:"is_live"`
|
|
DurationSeconds int64 `json:"duration_seconds"`
|
|
}
|
|
|
|
// GetLiveStreamMetrics returns current metrics for a live stream (F385)
|
|
func (s *CreatorAnalyticsService) GetLiveStreamMetrics(ctx context.Context, creatorID uuid.UUID, streamID uuid.UUID) (*LiveStreamMetrics, error) {
|
|
var stream struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
UserID uuid.UUID `gorm:"column:user_id"`
|
|
IsLive bool `gorm:"column:is_live"`
|
|
ViewerCount int `gorm:"column:viewer_count"`
|
|
StartedAt *time.Time `gorm:"column:started_at"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Table("live_streams").
|
|
Where("id = ? AND user_id = ?", streamID, creatorID).
|
|
First(&stream).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("live stream not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get live stream: %w", err)
|
|
}
|
|
|
|
metrics := &LiveStreamMetrics{
|
|
StreamID: streamID.String(),
|
|
CurrentViewers: stream.ViewerCount,
|
|
PeakViewers: stream.ViewerCount, // Could track separately in future
|
|
IsLive: stream.IsLive,
|
|
}
|
|
|
|
if stream.StartedAt != nil && stream.IsLive {
|
|
metrics.DurationSeconds = int64(time.Since(*stream.StartedAt).Seconds())
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
// GetTrackDetailedStats returns per-track stats for a creator (used in F381 dashboard)
|
|
type TrackDetailedStats struct {
|
|
TrackID string `json:"track_id"`
|
|
Title string `json:"title"`
|
|
TotalPlays int64 `json:"total_plays"`
|
|
CompleteListens int64 `json:"complete_listens"`
|
|
UniqueListeners int64 `json:"unique_listeners"`
|
|
AvgPlayDuration float64 `json:"avg_play_duration"`
|
|
AvgCompletion float64 `json:"avg_completion"`
|
|
}
|
|
|
|
// GetPerTrackStats returns detailed stats for each of the creator's tracks
|
|
func (s *CreatorAnalyticsService) GetPerTrackStats(ctx context.Context, creatorID uuid.UUID, startDate, endDate time.Time, limit, offset int) ([]TrackDetailedStats, int64, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
var total int64
|
|
s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", creatorID).Count(&total)
|
|
|
|
var rows []struct {
|
|
TrackID string `gorm:"column:track_id"`
|
|
Title string `gorm:"column:title"`
|
|
TotalPlays int64 `gorm:"column:total_plays"`
|
|
CompleteListens int64 `gorm:"column:complete_listens"`
|
|
UniqueListeners int64 `gorm:"column:unique_listeners"`
|
|
AvgPlayDuration float64 `gorm:"column:avg_play_duration"`
|
|
AvgCompletion float64 `gorm:"column:avg_completion"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT CAST(t.id AS TEXT) AS track_id, t.title,
|
|
COUNT(pa.id) AS total_plays,
|
|
COUNT(CASE WHEN pa.completion_rate >= 90 THEN 1 END) AS complete_listens,
|
|
COUNT(DISTINCT pa.user_id) AS unique_listeners,
|
|
COALESCE(AVG(pa.play_time), 0) AS avg_play_duration,
|
|
COALESCE(AVG(pa.completion_rate), 0) AS avg_completion
|
|
FROM tracks t
|
|
LEFT JOIN playback_analytics pa ON pa.track_id = t.id
|
|
AND pa.started_at >= ? AND pa.started_at <= ?
|
|
WHERE t.creator_id = ? AND t.deleted_at IS NULL
|
|
GROUP BY t.id, t.title
|
|
ORDER BY total_plays DESC
|
|
LIMIT ? OFFSET ?
|
|
`, startDate, endDate, creatorID, limit, offset).Scan(&rows).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to get per-track stats: %w", err)
|
|
}
|
|
|
|
result := make([]TrackDetailedStats, len(rows))
|
|
for i, r := range rows {
|
|
result[i] = TrackDetailedStats{
|
|
TrackID: r.TrackID,
|
|
Title: r.Title,
|
|
TotalPlays: r.TotalPlays,
|
|
CompleteListens: r.CompleteListens,
|
|
UniqueListeners: r.UniqueListeners,
|
|
AvgPlayDuration: r.AvgPlayDuration,
|
|
AvgCompletion: r.AvgCompletion,
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|