fix: sync completed state after playback

This commit is contained in:
2026-04-19 00:19:09 +02:00
parent 70ed0b5716
commit 88d5c6df60
4 changed files with 120 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log"
"net/http"
@@ -100,6 +101,10 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
pageData := templates.WatchPageData{
MalID: data.MalID,
Title: data.Title,
TitleEnglish: anime.TitleEnglish,
TitleJapanese: anime.TitleJapanese,
ImageURL: anime.ImageURL(),
Airing: anime.Airing,
CurrentEpisode: data.CurrentEpisode,
TotalEpisodes: anime.Episodes,
StartTimeSeconds: data.StartTimeSeconds,
@@ -279,6 +284,21 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
}
}
watchListEntry, watchListErr := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
UserID: user.ID,
AnimeID: animeID,
})
if watchListErr == nil && watchListEntry.Status == "completed" {
if err := h.svc.db.DeleteContinueWatchingEntry(r.Context(), database.DeleteContinueWatchingEntryParams{
UserID: user.ID,
AnimeID: animeID,
}); err != nil {
log.Printf("save progress failed to clear continue entry user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
}
w.WriteHeader(http.StatusNoContent)
return
}
if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
CurrentTimeSeconds: timeSeconds,
@@ -379,6 +399,30 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := h.svc.db.GetContinueWatchingEntry(r.Context(), database.GetContinueWatchingEntryParams{
UserID: user.ID,
AnimeID: animeID,
}); err == nil {
log.Printf("complete anime failed to clear continue entry user_id=%s mal_id=%d", user.ID, payload.MalID)
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
return
} else if !errors.Is(err, sql.ErrNoRows) {
log.Printf("complete anime failed to verify continue clear user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
return
}
if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
CurrentTimeSeconds: 0,
UserID: user.ID,
AnimeID: animeID,
}); err != nil {
if err.Error() != "sql: no rows in result set" {
log.Printf("complete anime failed to reset watchlist progress user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
}
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -13,6 +13,10 @@ import (
type WatchPageData struct {
MalID int
Title string
TitleEnglish string
TitleJapanese string
ImageURL string
Airing bool
CurrentEpisode string
TotalEpisodes int
StartTimeSeconds float64
@@ -88,7 +92,9 @@ templ WatchPage(anime jikan.Anime, data WatchPageData) {
} else {
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">Next ▶</span>
}
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing)
<span id="watch-status-dropdown">
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing)
</span>
</div>
<section>
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Watch more seasons of this anime</h3>
@@ -201,6 +207,11 @@ templ VideoPlayer(data WatchPageData) {
data-video-player
data-stream-url="/watch/proxy/stream"
data-current-episode={ data.CurrentEpisode }
data-anime-title={ data.Title }
data-anime-title-english={ data.TitleEnglish }
data-anime-title-japanese={ data.TitleJapanese }
data-anime-image={ data.ImageURL }
data-anime-airing={ fmt.Sprintf("%v", data.Airing) }
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
data-initial-mode={ data.InitialMode }
data-available-modes={ toJSON(data.AvailableModes) }

View File

@@ -107,7 +107,7 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
}
templ ifHasProgress(entry database.GetUserWatchListRow) {
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 && entry.Status != "completed" {
<p class="m-0 mt-1 text-xs text-(--text-faint)">
Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
if entry.CurrentTimeSeconds > 0 {

View File

@@ -49,6 +49,11 @@ const initPlayer = (): void => {
const currentEpisode = container.getAttribute('data-current-episode') || '1'
const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10)
const totalEpisodes = Number.parseInt(container.getAttribute('data-total-episodes') || '0', 10)
const animeTitle = container.getAttribute('data-anime-title') || ''
const animeTitleEnglish = container.getAttribute('data-anime-title-english') || ''
const animeTitleJapanese = container.getAttribute('data-anime-title-japanese') || ''
const animeImage = container.getAttribute('data-anime-image') || ''
const animeAiring = (container.getAttribute('data-anime-airing') || '').toLowerCase() === 'true'
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') || '[]')
@@ -86,6 +91,8 @@ const initPlayer = (): void => {
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
let progressSaveTimer: number | undefined
let transitionEpisode: number | null = null
let completionSent = false
let completionAttempts = 0
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
const previewTime = container.querySelector('[data-preview-time]') as HTMLElement
@@ -617,21 +624,75 @@ const initPlayer = (): void => {
}
const completeAnime = async (episodeNumber: number): Promise<void> => {
if (completionSent) return
if (!Number.isInteger(malID) || malID <= 0) return
if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return
completionSent = true
try {
await fetch('/api/watch-complete', {
const response = await fetch('/api/watch-complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
keepalive: true,
body: JSON.stringify({
mal_id: malID,
episode: episodeNumber,
}),
})
if (!response.ok) {
completionSent = false
if (completionAttempts < 2) {
completionAttempts += 1
window.setTimeout(() => {
completeAnime(episodeNumber)
}, 1000)
}
return
}
const dropdownTrigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
if (dropdownTrigger) {
dropdownTrigger.innerHTML = 'Completed <span class="text-xs">▾</span>'
}
const watchStatusDropdown = document.getElementById('watch-status-dropdown')
if (watchStatusDropdown) {
const payload = {
anime_id: String(malID),
anime_title: animeTitle,
anime_title_english: animeTitleEnglish,
anime_title_japanese: animeTitleJapanese,
anime_image: animeImage,
status: 'completed',
airing: animeAiring,
}
fetch('/api/watchlist', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'HX-Request': 'true',
},
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
credentials: 'same-origin',
}).then(async (res) => {
if (!res.ok) return
const html = await res.text()
watchStatusDropdown.outerHTML = `<span id="watch-status-dropdown">${html}</span>`
}).catch(() => {})
}
} catch {
completionSent = false
if (completionAttempts < 2) {
completionAttempts += 1
window.setTimeout(() => {
completeAnime(episodeNumber)
}, 1000)
}
return
}
}
@@ -830,6 +891,7 @@ const initPlayer = (): void => {
window.addEventListener('beforeunload', () => {
if (transitionEpisode !== null) return
if (completionSent) return
if (!Number.isInteger(malID) || malID <= 0) return
if (!video.duration || !Number.isFinite(video.duration)) return
const episodeNumber = Number.parseInt(currentEpisode, 10)