feat: save watch progress
This commit is contained in:
@@ -82,4 +82,5 @@ type WatchListEntry struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Querier interface {
|
|||||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||||
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
||||||
MarkRelationsSynced(ctx context.Context, id int64) error
|
MarkRelationsSynced(ctx context.Context, id int64) error
|
||||||
|
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||||
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
|
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
|
||||||
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
||||||
UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error
|
UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error
|
||||||
|
|||||||
@@ -46,14 +46,22 @@ RETURNING *;
|
|||||||
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: UpsertWatchListEntry :one
|
-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
current_episode = excluded.current_episode,
|
current_episode = excluded.current_episode,
|
||||||
|
current_time_seconds = excluded.current_time_seconds,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
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
|
-- name: GetWatchListEntry :one
|
||||||
SELECT * FROM watch_list_entry
|
SELECT * FROM watch_list_entry
|
||||||
WHERE user_id = ? AND anime_id = ? LIMIT 1;
|
WHERE user_id = ? AND anime_id = ? LIMIT 1;
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ func (q *Queries) GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg G
|
|||||||
|
|
||||||
const getUserWatchList = `-- name: GetUserWatchList :many
|
const getUserWatchList = `-- name: GetUserWatchList :many
|
||||||
SELECT
|
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_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -469,6 +469,7 @@ type GetUserWatchListRow struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
TitleOriginal string `json:"title_original"`
|
TitleOriginal string `json:"title_original"`
|
||||||
TitleEnglish sql.NullString `json:"title_english"`
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
@@ -494,6 +495,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
|
&i.CurrentTimeSeconds,
|
||||||
&i.TitleOriginal,
|
&i.TitleOriginal,
|
||||||
&i.TitleEnglish,
|
&i.TitleEnglish,
|
||||||
&i.TitleJapanese,
|
&i.TitleJapanese,
|
||||||
@@ -514,7 +516,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getWatchListEntry = `-- name: GetWatchListEntry :one
|
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
|
WHERE user_id = ? AND anime_id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -535,13 +537,14 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
|
&i.CurrentTimeSeconds,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWatchingAnime = `-- name: GetWatchingAnime :many
|
const getWatchingAnime = `-- name: GetWatchingAnime :many
|
||||||
SELECT
|
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_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -562,6 +565,7 @@ type GetWatchingAnimeRow struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
TitleOriginal string `json:"title_original"`
|
TitleOriginal string `json:"title_original"`
|
||||||
TitleEnglish sql.NullString `json:"title_english"`
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
@@ -587,6 +591,7 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
|
&i.CurrentTimeSeconds,
|
||||||
&i.TitleOriginal,
|
&i.TitleOriginal,
|
||||||
&i.TitleEnglish,
|
&i.TitleEnglish,
|
||||||
&i.TitleJapanese,
|
&i.TitleJapanese,
|
||||||
@@ -635,6 +640,31 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
|
|||||||
return err
|
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
|
const setJikanCache = `-- name: SetJikanCache :exec
|
||||||
INSERT INTO jikan_cache (key, data, expires_at)
|
INSERT INTO jikan_cache (key, data, expires_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -750,13 +780,14 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
current_episode = excluded.current_episode,
|
current_episode = excluded.current_episode,
|
||||||
|
current_time_seconds = excluded.current_time_seconds,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
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 {
|
type UpsertWatchListEntryParams struct {
|
||||||
@@ -765,6 +796,7 @@ type UpsertWatchListEntryParams struct {
|
|||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
|
CurrentTimeSeconds float64 `json:"current_time_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) {
|
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.AnimeID,
|
||||||
arg.Status,
|
arg.Status,
|
||||||
arg.CurrentEpisode,
|
arg.CurrentEpisode,
|
||||||
|
arg.CurrentTimeSeconds,
|
||||||
)
|
)
|
||||||
var i WatchListEntry
|
var i WatchListEntry
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -785,6 +818,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.CurrentEpisode,
|
&i.CurrentEpisode,
|
||||||
&i.LastEpisodeAt,
|
&i.LastEpisodeAt,
|
||||||
|
&i.CurrentTimeSeconds,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package playback
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -91,6 +92,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
MalID: data.MalID,
|
MalID: data.MalID,
|
||||||
Title: data.Title,
|
Title: data.Title,
|
||||||
CurrentEpisode: data.CurrentEpisode,
|
CurrentEpisode: data.CurrentEpisode,
|
||||||
|
StartTimeSeconds: data.StartTimeSeconds,
|
||||||
CurrentStatus: data.CurrentStatus,
|
CurrentStatus: data.CurrentStatus,
|
||||||
InitialMode: data.InitialMode,
|
InitialMode: data.InitialMode,
|
||||||
AvailableModes: data.AvailableModes,
|
AvailableModes: data.AvailableModes,
|
||||||
@@ -297,6 +299,67 @@ func (h *Handler) HandleProxyPreviewSprite(w http.ResponseWriter, r *http.Reques
|
|||||||
http.ServeFile(w, r, spritePath)
|
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) {
|
func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) {
|
||||||
parsed, err := url.Parse(targetURL)
|
parsed, err := url.Parse(targetURL)
|
||||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
|
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
|
||||||
|
|
||||||
currentStatus := ""
|
currentStatus := ""
|
||||||
|
startTimeSeconds := 0.0
|
||||||
if userID != "" && s.db != nil {
|
if userID != "" && s.db != nil {
|
||||||
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -111,6 +112,9 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
currentStatus = entry.Status
|
currentStatus = entry.Status
|
||||||
|
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == normalizedEpisode && entry.CurrentTimeSeconds > 0 {
|
||||||
|
startTimeSeconds = entry.CurrentTimeSeconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
MalID: malID,
|
MalID: malID,
|
||||||
Title: watchTitle,
|
Title: watchTitle,
|
||||||
CurrentEpisode: normalizedEpisode,
|
CurrentEpisode: normalizedEpisode,
|
||||||
|
StartTimeSeconds: startTimeSeconds,
|
||||||
CurrentStatus: currentStatus,
|
CurrentStatus: currentStatus,
|
||||||
InitialMode: initialMode,
|
InitialMode: initialMode,
|
||||||
AvailableModes: availableModes,
|
AvailableModes: availableModes,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type WatchPageData struct {
|
|||||||
MalID int
|
MalID int
|
||||||
Title string
|
Title string
|
||||||
CurrentEpisode string
|
CurrentEpisode string
|
||||||
|
StartTimeSeconds float64
|
||||||
CurrentStatus string
|
CurrentStatus string
|
||||||
InitialMode string
|
InitialMode string
|
||||||
AvailableModes []string
|
AvailableModes []string
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) e
|
|||||||
AnimeID: req.AnimeID,
|
AnimeID: req.AnimeID,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
|
CurrentTimeSeconds: 0,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update watchlist: %w", err)
|
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,
|
AnimeID: entry.AnimeID,
|
||||||
Status: entry.Status,
|
Status: entry.Status,
|
||||||
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
|
CurrentTimeSeconds: 0,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream)
|
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream)
|
||||||
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment)
|
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment)
|
||||||
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle)
|
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-map", playbackHandler.HandleProxyPreviewMap)
|
||||||
mux.HandleFunc("/watch/proxy/preview-sprite", playbackHandler.HandleProxyPreviewSprite)
|
mux.HandleFunc("/watch/proxy/preview-sprite", playbackHandler.HandleProxyPreviewSprite)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type WatchPageData struct {
|
|||||||
MalID int
|
MalID int
|
||||||
Title string
|
Title string
|
||||||
CurrentEpisode string
|
CurrentEpisode string
|
||||||
|
StartTimeSeconds float64
|
||||||
CurrentStatus string
|
CurrentStatus string
|
||||||
InitialMode string
|
InitialMode string
|
||||||
AvailableModes []string
|
AvailableModes []string
|
||||||
@@ -192,10 +193,12 @@ templ VideoPlayer(data WatchPageData) {
|
|||||||
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
|
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 w-full"
|
class="flex flex-col gap-4 w-full"
|
||||||
|
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||||
data-video-player
|
data-video-player
|
||||||
data-stream-url="/watch/proxy/stream"
|
data-stream-url="/watch/proxy/stream"
|
||||||
data-preview-map-url="/watch/proxy/preview-map"
|
data-preview-map-url="/watch/proxy/preview-map"
|
||||||
data-current-episode={ data.CurrentEpisode }
|
data-current-episode={ data.CurrentEpisode }
|
||||||
|
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
|
||||||
data-initial-mode={ data.InitialMode }
|
data-initial-mode={ data.InitialMode }
|
||||||
data-available-modes={ toJSON(data.AvailableModes) }
|
data-available-modes={ toJSON(data.AvailableModes) }
|
||||||
data-mode-sources={ toJSON(data.ModeSources) }
|
data-mode-sources={ toJSON(data.ModeSources) }
|
||||||
|
|||||||
2
migrations/010_add_watch_progress_seconds.sql
Normal file
2
migrations/010_add_watch_progress_seconds.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE watch_list_entry
|
||||||
|
ADD COLUMN current_time_seconds REAL NOT NULL DEFAULT 0;
|
||||||
@@ -115,6 +115,8 @@ const initPlayer = (): void => {
|
|||||||
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||||
const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map'
|
const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map'
|
||||||
const currentEpisode = container.getAttribute('data-current-episode') || '1'
|
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 modeSources = JSON.parse(container.getAttribute('data-mode-sources') || '{}')
|
||||||
const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]')
|
const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]')
|
||||||
const initialMode = container.getAttribute('data-initial-mode') || 'dub'
|
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 activeSegments: Array<{ type: string, start: number, end: number }> = []
|
||||||
let previewState: { [key: string]: PreviewPayload } = {}
|
let previewState: { [key: string]: PreviewPayload } = {}
|
||||||
let previewRequestToken = 0
|
let previewRequestToken = 0
|
||||||
|
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
||||||
|
let progressSaveTimer: number | undefined
|
||||||
|
|
||||||
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
||||||
const previewFrame = container.querySelector('[data-preview-frame]') 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 parseVttTime = (raw: string): number => {
|
||||||
const parts = raw.trim().split(':')
|
const parts = raw.trim().split(':')
|
||||||
if (parts.length < 2) return 0
|
if (parts.length < 2) return 0
|
||||||
@@ -611,6 +656,14 @@ const initPlayer = (): void => {
|
|||||||
if (loading) loading.style.display = 'none'
|
if (loading) loading.style.display = 'none'
|
||||||
resolveActiveSegments()
|
resolveActiveSegments()
|
||||||
renderSegments()
|
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)) {
|
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
|
||||||
try {
|
try {
|
||||||
video.currentTime = pendingSeekTime
|
video.currentTime = pendingSeekTime
|
||||||
@@ -634,6 +687,7 @@ const initPlayer = (): void => {
|
|||||||
updateTimeline(video.currentTime)
|
updateTimeline(video.currentTime)
|
||||||
updateSubtitleRender(video.currentTime)
|
updateSubtitleRender(video.currentTime)
|
||||||
updateSkipButton(video.currentTime)
|
updateSkipButton(video.currentTime)
|
||||||
|
scheduleProgressSave()
|
||||||
})
|
})
|
||||||
|
|
||||||
video.addEventListener('play', () => {
|
video.addEventListener('play', () => {
|
||||||
@@ -644,6 +698,9 @@ const initPlayer = (): void => {
|
|||||||
video.addEventListener('pause', () => {
|
video.addEventListener('pause', () => {
|
||||||
updatePlayPauseIcons(false)
|
updatePlayPauseIcons(false)
|
||||||
showControls()
|
showControls()
|
||||||
|
window.clearTimeout(progressSaveTimer)
|
||||||
|
progressSaveTimer = undefined
|
||||||
|
saveProgress()
|
||||||
})
|
})
|
||||||
|
|
||||||
video.addEventListener('volumechange', () => {
|
video.addEventListener('volumechange', () => {
|
||||||
@@ -818,6 +875,7 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
window.addEventListener('mouseup', () => {
|
||||||
isScrubbing = false
|
isScrubbing = false
|
||||||
|
saveProgress()
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousemove', (event) => {
|
window.addEventListener('mousemove', (event) => {
|
||||||
@@ -855,6 +913,23 @@ const initPlayer = (): void => {
|
|||||||
showControls()
|
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)
|
updatePlayPauseIcons(false)
|
||||||
syncVolumeUI()
|
syncVolumeUI()
|
||||||
updateSkipButton(0)
|
updateSkipButton(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user