feat: save watch progress

This commit is contained in:
2026-04-18 18:24:43 +02:00
parent 026a105e12
commit c1ee5df94c
12 changed files with 252 additions and 56 deletions

View File

@@ -74,12 +74,13 @@ type User struct {
}
type WatchListEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
}

View File

@@ -33,6 +33,7 @@ type Querier interface {
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
MarkRelationsSynced(ctx context.Context, id int64) error
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error

View File

@@ -46,14 +46,22 @@ RETURNING *;
SELECT * FROM anime WHERE id = ? LIMIT 1;
-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: SaveWatchProgress :exec
UPDATE watch_list_entry
SET current_episode = ?,
current_time_seconds = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND anime_id = ?;
-- name: GetWatchListEntry :one
SELECT * FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1;

View File

@@ -448,7 +448,7 @@ func (q *Queries) GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg G
const getUserWatchList = `-- name: GetUserWatchList :many
SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at,
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
a.title_original,
a.title_english,
a.title_japanese,
@@ -461,19 +461,20 @@ ORDER BY e.updated_at DESC
`
type GetUserWatchListRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
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 string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
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"`
}
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
@@ -494,6 +495,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
&i.UpdatedAt,
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
@@ -514,7 +516,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
}
const getWatchListEntry = `-- name: GetWatchListEntry :one
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at FROM watch_list_entry
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1
`
@@ -535,13 +537,14 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
&i.UpdatedAt,
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
)
return i, err
}
const getWatchingAnime = `-- name: GetWatchingAnime :many
SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at,
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
a.title_original,
a.title_english,
a.title_japanese,
@@ -554,19 +557,20 @@ ORDER BY e.updated_at DESC
`
type GetWatchingAnimeRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
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 string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
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"`
}
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
@@ -587,6 +591,7 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
&i.UpdatedAt,
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
@@ -635,6 +640,31 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
return err
}
const saveWatchProgress = `-- name: SaveWatchProgress :exec
UPDATE watch_list_entry
SET current_episode = ?,
current_time_seconds = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND anime_id = ?
`
type SaveWatchProgressParams struct {
CurrentEpisode sql.NullInt64 `json:"current_episode"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
}
func (q *Queries) SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error {
_, err := q.db.ExecContext(ctx, saveWatchProgress,
arg.CurrentEpisode,
arg.CurrentTimeSeconds,
arg.UserID,
arg.AnimeID,
)
return err
}
const setJikanCache = `-- name: SetJikanCache :exec
INSERT INTO jikan_cache (key, data, expires_at)
VALUES (?, ?, ?)
@@ -750,21 +780,23 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati
}
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds
`
type UpsertWatchListEntryParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
}
func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) {
@@ -774,6 +806,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
arg.AnimeID,
arg.Status,
arg.CurrentEpisode,
arg.CurrentTimeSeconds,
)
var i WatchListEntry
err := row.Scan(
@@ -785,6 +818,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
&i.UpdatedAt,
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
)
return i, err
}

View File

@@ -2,6 +2,7 @@ package playback
import (
"context"
"database/sql"
"encoding/json"
"io"
"log"
@@ -91,6 +92,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
MalID: data.MalID,
Title: data.Title,
CurrentEpisode: data.CurrentEpisode,
StartTimeSeconds: data.StartTimeSeconds,
CurrentStatus: data.CurrentStatus,
InitialMode: data.InitialMode,
AvailableModes: data.AvailableModes,
@@ -297,6 +299,67 @@ func (h *Handler) HandleProxyPreviewSprite(w http.ResponseWriter, r *http.Reques
http.ServeFile(w, r, spritePath)
}
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
user := middleware.GetUser(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
type saveProgressRequest struct {
MalID int `json:"mal_id"`
Episode int `json:"episode"`
TimeSecond float64 `json:"time_seconds"`
}
var payload saveProgressRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if payload.MalID <= 0 || payload.Episode <= 0 {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
timeSeconds := payload.TimeSecond
if timeSeconds < 0 || timeSeconds != timeSeconds {
timeSeconds = 0
}
if h.svc.db == nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
if _, err := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
UserID: user.ID,
AnimeID: int64(payload.MalID),
}); err != nil {
http.Error(w, "watchlist entry not found", http.StatusNotFound)
return
}
if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
CurrentTimeSeconds: timeSeconds,
UserID: user.ID,
AnimeID: int64(payload.MalID),
}); err != nil {
log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
http.Error(w, "failed to save progress", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) {
parsed, err := url.Parse(targetURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {

View File

@@ -104,6 +104,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
currentStatus := ""
startTimeSeconds := 0.0
if userID != "" && s.db != nil {
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
UserID: userID,
@@ -111,6 +112,9 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
})
if err == nil {
currentStatus = entry.Status
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == normalizedEpisode && entry.CurrentTimeSeconds > 0 {
startTimeSeconds = entry.CurrentTimeSeconds
}
}
}
@@ -123,15 +127,16 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
}
return WatchPageData{
MalID: malID,
Title: watchTitle,
CurrentEpisode: normalizedEpisode,
CurrentStatus: currentStatus,
InitialMode: initialMode,
AvailableModes: availableModes,
ModeSources: modeSources,
Episodes: episodes,
Segments: segments,
MalID: malID,
Title: watchTitle,
CurrentEpisode: normalizedEpisode,
StartTimeSeconds: startTimeSeconds,
CurrentStatus: currentStatus,
InitialMode: initialMode,
AvailableModes: availableModes,
ModeSources: modeSources,
Episodes: episodes,
Segments: segments,
}, nil
}

View File

@@ -44,6 +44,7 @@ type WatchPageData struct {
MalID int
Title string
CurrentEpisode string
StartTimeSeconds float64
CurrentStatus string
InitialMode string
AvailableModes []string

View File

@@ -71,6 +71,7 @@ func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) e
AnimeID: req.AnimeID,
Status: req.Status,
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
return fmt.Errorf("failed to update watchlist: %w", err)
@@ -165,6 +166,7 @@ func (s *Service) Import(ctx context.Context, userID string, export ExportData)
AnimeID: entry.AnimeID,
Status: entry.Status,
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
continue

View File

@@ -58,6 +58,7 @@ func NewRouter(cfg Config) http.Handler {
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream)
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment)
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle)
mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress)
mux.HandleFunc("/watch/proxy/preview-map", playbackHandler.HandleProxyPreviewMap)
mux.HandleFunc("/watch/proxy/preview-sprite", playbackHandler.HandleProxyPreviewSprite)

View File

@@ -14,6 +14,7 @@ type WatchPageData struct {
MalID int
Title string
CurrentEpisode string
StartTimeSeconds float64
CurrentStatus string
InitialMode string
AvailableModes []string
@@ -192,10 +193,12 @@ templ VideoPlayer(data WatchPageData) {
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
<div
class="flex flex-col gap-4 w-full"
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
data-video-player
data-stream-url="/watch/proxy/stream"
data-preview-map-url="/watch/proxy/preview-map"
data-current-episode={ data.CurrentEpisode }
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
data-initial-mode={ data.InitialMode }
data-available-modes={ toJSON(data.AvailableModes) }
data-mode-sources={ toJSON(data.ModeSources) }

View File

@@ -0,0 +1,2 @@
ALTER TABLE watch_list_entry
ADD COLUMN current_time_seconds REAL NOT NULL DEFAULT 0;

View File

@@ -115,6 +115,8 @@ const initPlayer = (): void => {
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map'
const currentEpisode = container.getAttribute('data-current-episode') || '1'
const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10)
const startTimeSeconds = Number.parseFloat(container.getAttribute('data-start-time-seconds') || '0')
const modeSources = JSON.parse(container.getAttribute('data-mode-sources') || '{}')
const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]')
const initialMode = container.getAttribute('data-initial-mode') || 'dub'
@@ -157,6 +159,8 @@ const initPlayer = (): void => {
let activeSegments: Array<{ type: string, start: number, end: number }> = []
let previewState: { [key: string]: PreviewPayload } = {}
let previewRequestToken = 0
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
let progressSaveTimer: number | undefined
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
const previewFrame = container.querySelector('[data-preview-frame]') as HTMLElement
@@ -411,6 +415,47 @@ const initPlayer = (): void => {
}
}
const saveProgress = async (): Promise<void> => {
if (!Number.isInteger(malID) || malID <= 0) return
if (!video.duration || !Number.isFinite(video.duration)) return
const episodeNumber = Number.parseInt(currentEpisode, 10)
if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return
const safeTime = Math.max(0, Math.min(video.currentTime, video.duration))
if (lastSavedProgress.episode === currentEpisode && Math.abs(lastSavedProgress.seconds - safeTime) < 2) {
return
}
try {
const response = await fetch('/api/watch-progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mal_id: malID,
episode: episodeNumber,
time_seconds: safeTime,
}),
})
if (!response.ok) return
lastSavedProgress = {
episode: currentEpisode,
seconds: safeTime,
}
} catch {
return
}
}
const scheduleProgressSave = (): void => {
if (progressSaveTimer !== undefined) return
progressSaveTimer = window.setTimeout(() => {
progressSaveTimer = undefined
saveProgress()
}, 1500)
}
const parseVttTime = (raw: string): number => {
const parts = raw.trim().split(':')
if (parts.length < 2) return 0
@@ -611,6 +656,14 @@ const initPlayer = (): void => {
if (loading) loading.style.display = 'none'
resolveActiveSegments()
renderSegments()
if (Number.isFinite(startTimeSeconds) && startTimeSeconds > 0 && video.currentTime === 0) {
const nextStart = Math.min(startTimeSeconds, Math.max(0, video.duration - 0.5))
if (nextStart > 0) {
try {
video.currentTime = nextStart
} catch {}
}
}
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
try {
video.currentTime = pendingSeekTime
@@ -634,6 +687,7 @@ const initPlayer = (): void => {
updateTimeline(video.currentTime)
updateSubtitleRender(video.currentTime)
updateSkipButton(video.currentTime)
scheduleProgressSave()
})
video.addEventListener('play', () => {
@@ -644,6 +698,9 @@ const initPlayer = (): void => {
video.addEventListener('pause', () => {
updatePlayPauseIcons(false)
showControls()
window.clearTimeout(progressSaveTimer)
progressSaveTimer = undefined
saveProgress()
})
video.addEventListener('volumechange', () => {
@@ -818,6 +875,7 @@ const initPlayer = (): void => {
window.addEventListener('mouseup', () => {
isScrubbing = false
saveProgress()
})
window.addEventListener('mousemove', (event) => {
@@ -855,6 +913,23 @@ const initPlayer = (): void => {
showControls()
})
window.addEventListener('beforeunload', () => {
if (!Number.isInteger(malID) || malID <= 0) return
if (!video.duration || !Number.isFinite(video.duration)) return
const episodeNumber = Number.parseInt(currentEpisode, 10)
if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return
const safeTime = Math.max(0, Math.min(video.currentTime, video.duration))
const payload = JSON.stringify({
mal_id: malID,
episode: episodeNumber,
time_seconds: safeTime,
})
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' })
navigator.sendBeacon('/api/watch-progress', blob)
}
})
updatePlayPauseIcons(false)
syncVolumeUI()
updateSkipButton(0)