From 248f234f734a2857b8487248cee8d2d0bccf6910 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 2 May 2026 20:09:13 +0200 Subject: [PATCH] fix: calculate actual progress percentage for continue watching --- api/playback/handler.go | 26 ++++--- api/playback/progress.go | 6 ++ integrations/jikan/types.go | 40 ++++++++++ internal/db/models.go | 16 ++-- internal/db/queries.sql | 16 ++-- internal/db/queries.sql.go | 74 +++++++++++-------- migrations/015_add_duration.sql | 5 ++ templates/components/continue_watching.gohtml | 5 +- templates/renderer.go | 15 ++++ 9 files changed, 145 insertions(+), 58 deletions(-) create mode 100644 migrations/015_add_duration.sql diff --git a/api/playback/handler.go b/api/playback/handler.go index 908f340..e7e11a8 100644 --- a/api/playback/handler.go +++ b/api/playback/handler.go @@ -254,12 +254,13 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { var seed *database.UpsertAnimeParams if err == nil { seed = &database.UpsertAnimeParams{ - ID: int64(anime.MalID), - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.ImageURL(), - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + ID: int64(anime.MalID), + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.ImageURL(), + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0}, } } @@ -299,12 +300,13 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) { var seed *database.UpsertAnimeParams if err == nil { seed = &database.UpsertAnimeParams{ - ID: int64(anime.MalID), - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.ImageURL(), - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + ID: int64(anime.MalID), + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.ImageURL(), + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0}, } } diff --git a/api/playback/progress.go b/api/playback/progress.go index 9e75514..9c5d3a4 100644 --- a/api/playback/progress.go +++ b/api/playback/progress.go @@ -50,12 +50,18 @@ func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64 } } + var durationSeconds sql.NullFloat64 + if animeSeed != nil { + durationSeconds = animeSeed.DurationSeconds + } + if _, err := txQueries.UpsertContinueWatchingEntry(ctx, database.UpsertContinueWatchingEntryParams{ ID: uuid.New().String(), UserID: userID, AnimeID: animeID, CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true}, CurrentTimeSeconds: timeSeconds, + DurationSeconds: durationSeconds, }); err != nil { return fmt.Errorf("failed to upsert continue entry: %w", err) } diff --git a/integrations/jikan/types.go b/integrations/jikan/types.go index e205b10..1318ce7 100644 --- a/integrations/jikan/types.go +++ b/integrations/jikan/types.go @@ -114,6 +114,46 @@ func (a Anime) ShortDuration() string { return a.Duration } +func (a Anime) DurationSeconds() float64 { + if a.Duration == "" { + return 0 + } + var hours, minutes int + var isHours bool + var currentNum string + + for _, c := range a.Duration { + if c >= '0' && c <= '9' { + currentNum += string(c) + } else if c == ' ' && currentNum != "" { + val := 0 + fmt.Sscanf(currentNum, "%d", &val) + if isHours { + hours = val + } else { + minutes = val + } + currentNum = "" + } else if len(currentNum) > 0 && (c == 'h' || c == 'H') { + isHours = true + val := 0 + fmt.Sscanf(currentNum, "%d", &val) + hours = val + currentNum = "" + } + } + if currentNum != "" { + val := 0 + fmt.Sscanf(currentNum, "%d", &val) + if isHours { + hours = val + } else { + minutes = val + } + } + return float64(hours*60 + minutes) * 60 +} + func (a Anime) Premiered() string { if a.Season != "" && a.Year > 0 { return fmt.Sprintf("%s %d", seasonLabel(a.Season), a.Year) diff --git a/internal/db/models.go b/internal/db/models.go index 8a2e5ee..aef139a 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -19,6 +19,7 @@ type Anime struct { Airing sql.NullBool `json:"airing"` Status sql.NullString `json:"status"` RelationsSyncedAt sql.NullTime `json:"relations_synced_at"` + DurationSeconds sql.NullFloat64 `json:"duration_seconds"` } type AnimeFetchRetry struct { @@ -37,13 +38,14 @@ type AnimeRelation struct { } type ContinueWatchingEntry struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + DurationSeconds sql.NullFloat64 `json:"duration_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type JikanCache struct { diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 9621725..784e2fc 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -16,14 +16,15 @@ SELECT * FROM session WHERE id = ? LIMIT 1; DELETE FROM session WHERE id = ?; -- name: UpsertAnime :one -INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) +VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET title_original = excluded.title_original, title_english = excluded.title_english, title_japanese = excluded.title_japanese, image_url = excluded.image_url, - airing = excluded.airing + airing = excluded.airing, + duration_seconds = excluded.duration_seconds RETURNING *; -- name: GetAnime :one @@ -47,11 +48,12 @@ SET current_episode = ?, WHERE user_id = ? AND anime_id = ?; -- name: UpsertContinueWatchingEntry :one -INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at) -VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET current_episode = excluded.current_episode, current_time_seconds = excluded.current_time_seconds, + duration_seconds = excluded.duration_seconds, updated_at = CURRENT_TIMESTAMP RETURNING *; @@ -66,12 +68,14 @@ SELECT c.anime_id, c.current_episode, c.current_time_seconds, + c.duration_seconds, c.created_at, c.updated_at, a.title_original, a.title_english, a.title_japanese, - a.image_url + a.image_url, + a.duration_seconds as anime_duration_seconds FROM continue_watching_entry c JOIN anime a ON c.anime_id = a.id WHERE c.user_id = ? diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index 322a454..a0f9a02 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -207,12 +207,14 @@ SELECT c.anime_id, c.current_episode, c.current_time_seconds, + c.duration_seconds, c.created_at, c.updated_at, a.title_original, a.title_english, a.title_japanese, - a.image_url + a.image_url, + a.duration_seconds as anime_duration_seconds FROM continue_watching_entry c JOIN anime a ON c.anime_id = a.id WHERE c.user_id = ? @@ -220,17 +222,19 @@ ORDER BY c.updated_at DESC ` type GetContinueWatchingEntriesRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + DurationSeconds sql.NullFloat64 `json:"duration_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + AnimeDurationSeconds sql.NullFloat64 `json:"anime_duration_seconds"` } func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) { @@ -248,12 +252,14 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string) &i.AnimeID, &i.CurrentEpisode, &i.CurrentTimeSeconds, + &i.DurationSeconds, &i.CreatedAt, &i.UpdatedAt, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, &i.ImageUrl, + &i.AnimeDurationSeconds, ); err != nil { return nil, err } @@ -744,24 +750,26 @@ func (q *Queries) UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusPa } const upsertAnime = `-- name: UpsertAnime :one -INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) +VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET title_original = excluded.title_original, title_english = excluded.title_english, title_japanese = excluded.title_japanese, image_url = excluded.image_url, - airing = excluded.airing -RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at + airing = excluded.airing, + duration_seconds = excluded.duration_seconds +RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at, duration_seconds ` type UpsertAnimeParams struct { - ID int64 `json:"id"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` + DurationSeconds sql.NullFloat64 `json:"duration_seconds"` } func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) { @@ -772,6 +780,7 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime arg.TitleJapanese, arg.ImageUrl, arg.Airing, + arg.DurationSeconds, ) var i Anime err := row.Scan( @@ -784,6 +793,7 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime &i.Airing, &i.Status, &i.RelationsSyncedAt, + &i.DurationSeconds, ) return i, err } @@ -807,21 +817,23 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati } const upsertContinueWatchingEntry = `-- name: UpsertContinueWatchingEntry :one -INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at) -VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET current_episode = excluded.current_episode, current_time_seconds = excluded.current_time_seconds, + duration_seconds = excluded.duration_seconds, updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, anime_id, current_episode, current_time_seconds, created_at, updated_at +RETURNING id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, created_at, updated_at ` type UpsertContinueWatchingEntryParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + DurationSeconds sql.NullFloat64 `json:"duration_seconds"` } func (q *Queries) UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error) { @@ -831,6 +843,7 @@ func (q *Queries) UpsertContinueWatchingEntry(ctx context.Context, arg UpsertCon arg.AnimeID, arg.CurrentEpisode, arg.CurrentTimeSeconds, + arg.DurationSeconds, ) var i ContinueWatchingEntry err := row.Scan( @@ -839,6 +852,7 @@ func (q *Queries) UpsertContinueWatchingEntry(ctx context.Context, arg UpsertCon &i.AnimeID, &i.CurrentEpisode, &i.CurrentTimeSeconds, + &i.DurationSeconds, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/migrations/015_add_duration.sql b/migrations/015_add_duration.sql new file mode 100644 index 0000000..80057a5 --- /dev/null +++ b/migrations/015_add_duration.sql @@ -0,0 +1,5 @@ +-- Add duration column to anime table to store episode duration in seconds +ALTER TABLE anime ADD COLUMN duration_seconds REAL; + +-- Add duration_seconds column to continue_watching_entry to track episode duration +ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; \ No newline at end of file diff --git a/templates/components/continue_watching.gohtml b/templates/components/continue_watching.gohtml index 54a5fb7..cfb6bc8 100644 --- a/templates/components/continue_watching.gohtml +++ b/templates/components/continue_watching.gohtml @@ -21,10 +21,9 @@ - {{if .CurrentTimeSeconds}} - + {{if and .CurrentTimeSeconds .AnimeDurationSeconds.Valid}}
-
+
{{end}} diff --git a/templates/renderer.go b/templates/renderer.go index 6cc7c01..2981441 100644 --- a/templates/renderer.go +++ b/templates/renderer.go @@ -62,6 +62,21 @@ func GetRenderer() *Renderer { "sub": func(a, b int) int { return a - b }, + "mul": func(a, b float64) float64 { + return a * b + }, + "div": func(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b + }, + "percent": func(current, total float64) float64 { + if total == 0 { + return 0 + } + return (current / total) * 100 + }, } pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml"))