fix: sync completed state after playback
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user