From 88d5c6df60836956aab597204096169e64450373 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 19 Apr 2026 00:19:09 +0200 Subject: [PATCH] fix: sync completed state after playback --- internal/features/playback/handler.go | 44 ++++++++++++++++++ internal/templates/watch.templ | 13 +++++- internal/templates/watchlist.templ | 2 +- static/player.ts | 64 ++++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index c440327..c289374 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -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) } diff --git a/internal/templates/watch.templ b/internal/templates/watch.templ index a9398d5..a3e1a33 100644 --- a/internal/templates/watch.templ +++ b/internal/templates/watch.templ @@ -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 { Next ▶ } - @WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing) + + @WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing) +

Watch more seasons of this anime

@@ -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) } diff --git a/internal/templates/watchlist.templ b/internal/templates/watchlist.templ index 408d50a..549521c 100644 --- a/internal/templates/watchlist.templ +++ b/internal/templates/watchlist.templ @@ -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" {

Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) } if entry.CurrentTimeSeconds > 0 { diff --git a/static/player.ts b/static/player.ts index 2ac9343..74de089 100644 --- a/static/player.ts +++ b/static/player.ts @@ -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 => { + 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 ' + } + + 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 = `${html}` + }).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)