fix: sync completed state after playback
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -100,6 +101,10 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
pageData := templates.WatchPageData{
|
pageData := templates.WatchPageData{
|
||||||
MalID: data.MalID,
|
MalID: data.MalID,
|
||||||
Title: data.Title,
|
Title: data.Title,
|
||||||
|
TitleEnglish: anime.TitleEnglish,
|
||||||
|
TitleJapanese: anime.TitleJapanese,
|
||||||
|
ImageURL: anime.ImageURL(),
|
||||||
|
Airing: anime.Airing,
|
||||||
CurrentEpisode: data.CurrentEpisode,
|
CurrentEpisode: data.CurrentEpisode,
|
||||||
TotalEpisodes: anime.Episodes,
|
TotalEpisodes: anime.Episodes,
|
||||||
StartTimeSeconds: data.StartTimeSeconds,
|
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{
|
if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{
|
||||||
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
|
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
|
||||||
CurrentTimeSeconds: timeSeconds,
|
CurrentTimeSeconds: timeSeconds,
|
||||||
@@ -379,6 +399,30 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
type WatchPageData struct {
|
type WatchPageData struct {
|
||||||
MalID int
|
MalID int
|
||||||
Title string
|
Title string
|
||||||
|
TitleEnglish string
|
||||||
|
TitleJapanese string
|
||||||
|
ImageURL string
|
||||||
|
Airing bool
|
||||||
CurrentEpisode string
|
CurrentEpisode string
|
||||||
TotalEpisodes int
|
TotalEpisodes int
|
||||||
StartTimeSeconds float64
|
StartTimeSeconds float64
|
||||||
@@ -88,7 +92,9 @@ templ WatchPage(anime jikan.Anime, data WatchPageData) {
|
|||||||
} else {
|
} else {
|
||||||
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">Next ▶</span>
|
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">Next ▶</span>
|
||||||
}
|
}
|
||||||
|
<span id="watch-status-dropdown">
|
||||||
@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)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Watch more seasons of this anime</h3>
|
<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-video-player
|
||||||
data-stream-url="/watch/proxy/stream"
|
data-stream-url="/watch/proxy/stream"
|
||||||
data-current-episode={ data.CurrentEpisode }
|
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-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) }
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ ifHasProgress(entry database.GetUserWatchListRow) {
|
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)">
|
<p class="m-0 mt-1 text-xs text-(--text-faint)">
|
||||||
Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
|
Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
|
||||||
if entry.CurrentTimeSeconds > 0 {
|
if entry.CurrentTimeSeconds > 0 {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const initPlayer = (): void => {
|
|||||||
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 malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10)
|
||||||
const totalEpisodes = Number.parseInt(container.getAttribute('data-total-episodes') || '0', 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 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') || '[]')
|
||||||
@@ -86,6 +91,8 @@ const initPlayer = (): void => {
|
|||||||
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
||||||
let progressSaveTimer: number | undefined
|
let progressSaveTimer: number | undefined
|
||||||
let transitionEpisode: number | null = null
|
let transitionEpisode: number | null = null
|
||||||
|
let completionSent = false
|
||||||
|
let completionAttempts = 0
|
||||||
|
|
||||||
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
||||||
const previewTime = container.querySelector('[data-preview-time]') 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> => {
|
const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||||
|
if (completionSent) return
|
||||||
if (!Number.isInteger(malID) || malID <= 0) return
|
if (!Number.isInteger(malID) || malID <= 0) return
|
||||||
if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return
|
if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return
|
||||||
|
|
||||||
|
completionSent = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/watch-complete', {
|
const response = await fetch('/api/watch-complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
keepalive: true,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mal_id: malID,
|
mal_id: malID,
|
||||||
episode: episodeNumber,
|
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 {
|
} catch {
|
||||||
|
completionSent = false
|
||||||
|
if (completionAttempts < 2) {
|
||||||
|
completionAttempts += 1
|
||||||
|
window.setTimeout(() => {
|
||||||
|
completeAnime(episodeNumber)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,6 +891,7 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (transitionEpisode !== null) return
|
if (transitionEpisode !== null) return
|
||||||
|
if (completionSent) return
|
||||||
if (!Number.isInteger(malID) || malID <= 0) return
|
if (!Number.isInteger(malID) || malID <= 0) return
|
||||||
if (!video.duration || !Number.isFinite(video.duration)) return
|
if (!video.duration || !Number.isFinite(video.duration)) return
|
||||||
const episodeNumber = Number.parseInt(currentEpisode, 10)
|
const episodeNumber = Number.parseInt(currentEpisode, 10)
|
||||||
|
|||||||
Reference in New Issue
Block a user