fix: harden watch playback flow
This commit is contained in:
@@ -88,9 +88,9 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title := anime.DisplayTitle()
|
titleCandidates := playbackTitleCandidates(anime)
|
||||||
userID := watchlistUserIDFromRequest(r)
|
userID := watchlistUserIDFromRequest(r)
|
||||||
data, err := h.svc.BuildWatchPageData(ctx, malID, title, episode, mode, userID)
|
data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("watch page error for mal_id=%d: %v", malID, err)
|
log.Printf("watch page error for mal_id=%d: %v", malID, err)
|
||||||
http.Error(w, "Failed to load playback", http.StatusBadGateway)
|
http.Error(w, "Failed to load playback", http.StatusBadGateway)
|
||||||
@@ -127,6 +127,35 @@ func watchlistUserIDFromRequest(r *http.Request) string {
|
|||||||
return user.ID
|
return user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func playbackTitleCandidates(anime jikan.Anime) []string {
|
||||||
|
out := make([]string, 0, 3+len(anime.TitleSynonyms))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
add := func(value string) {
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
if normalized == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToLower(normalized)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(anime.Title)
|
||||||
|
add(anime.TitleEnglish)
|
||||||
|
add(anime.TitleJapanese)
|
||||||
|
for _, synonym := range anime.TitleSynonyms {
|
||||||
|
add(synonym)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func convertModeSources(sources map[string]ModeSource) map[string]templates.ModeSource {
|
func convertModeSources(sources map[string]ModeSource) map[string]templates.ModeSource {
|
||||||
result := make(map[string]templates.ModeSource, len(sources))
|
result := make(map[string]templates.ModeSource, len(sources))
|
||||||
for k, v := range sources {
|
for k, v := range sources {
|
||||||
@@ -354,7 +383,19 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animeID := int64(payload.MalID)
|
animeID := int64(payload.MalID)
|
||||||
|
watchListEntry, watchListErr := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
AnimeID: animeID,
|
||||||
|
})
|
||||||
|
if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) {
|
||||||
|
log.Printf("complete anime failed to load watchlist user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, watchListErr)
|
||||||
|
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadyCompleted := watchListErr == nil && watchListEntry.Status == "completed"
|
||||||
|
|
||||||
|
if !alreadyCompleted {
|
||||||
if _, err := h.svc.db.GetAnime(r.Context(), animeID); err != nil {
|
if _, err := h.svc.db.GetAnime(r.Context(), animeID); err != nil {
|
||||||
anime, fetchErr := h.jikanClient.GetAnimeByID(r.Context(), payload.MalID)
|
anime, fetchErr := h.jikanClient.GetAnimeByID(r.Context(), payload.MalID)
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
@@ -389,6 +430,7 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.db.DeleteContinueWatchingEntry(r.Context(), database.DeleteContinueWatchingEntryParams{
|
if err := h.svc.db.DeleteContinueWatchingEntry(r.Context(), database.DeleteContinueWatchingEntryParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -418,7 +460,7 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
AnimeID: animeID,
|
AnimeID: animeID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if err.Error() != "sql: no rows in result set" {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
log.Printf("complete anime failed to reset watchlist progress user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
log.Printf("complete anime failed to reset watchlist progress user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,6 +480,10 @@ func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetUR
|
|||||||
|
|
||||||
statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range"))
|
statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("proxy error for url=%s: %v", targetURL, err)
|
log.Printf("proxy error for url=%s: %v", targetURL, err)
|
||||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func NewService(db database.Querier) *Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title string, episode string, mode string, userID string) (WatchPageData, error) {
|
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) {
|
||||||
if malID <= 0 {
|
if malID <= 0 {
|
||||||
return WatchPageData{}, errors.New("invalid mal id")
|
return WatchPageData{}, errors.New("invalid mal id")
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
cacheKey := playbackDataCacheKey(malID, normalizedEpisode)
|
cacheKey := playbackDataCacheKey(malID, normalizedEpisode)
|
||||||
baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey)
|
baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey)
|
||||||
if !cacheHit {
|
if !cacheHit {
|
||||||
showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, title)
|
showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, titleCandidates)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WatchPageData{}, err
|
return WatchPageData{}, err
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
|
|
||||||
watchTitle := strings.TrimSpace(resolvedTitle)
|
watchTitle := strings.TrimSpace(resolvedTitle)
|
||||||
if watchTitle == "" {
|
if watchTitle == "" {
|
||||||
watchTitle = strings.TrimSpace(title)
|
watchTitle = firstNonEmptyTitle(titleCandidates)
|
||||||
}
|
}
|
||||||
if watchTitle == "" {
|
if watchTitle == "" {
|
||||||
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
||||||
@@ -233,7 +233,7 @@ func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) {
|
|||||||
s.cacheMu.Unlock()
|
s.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveShowCached(ctx context.Context, malID int, title string) (string, string, error) {
|
func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
s.cacheMu.RLock()
|
s.cacheMu.RLock()
|
||||||
@@ -244,7 +244,7 @@ func (s *Service) resolveShowCached(ctx context.Context, malID int, title string
|
|||||||
return item.ShowID, item.Title, nil
|
return item.ShowID, item.Title, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
showID, resolvedTitle, err := s.resolveShow(ctx, malID, title)
|
showID, resolvedTitle, err := s.resolveShow(ctx, malID, titleCandidates)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -365,33 +365,13 @@ func cloneSegments(segments []SkipSegment) []SkipSegment {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveShow(ctx context.Context, malID int, title string) (string, string, error) {
|
func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
|
||||||
malText := strconv.Itoa(malID)
|
malText := strconv.Itoa(malID)
|
||||||
modeCandidates := []string{"sub", "dub"}
|
modeCandidates := []string{"sub", "dub"}
|
||||||
|
queries := buildTitleSearchQueries(titleCandidates)
|
||||||
|
|
||||||
resultsByMode := make(map[string][]searchResult, len(modeCandidates))
|
for _, query := range queries {
|
||||||
searchCh := make(chan searchModeResult, len(modeCandidates))
|
resultsByMode := s.searchShowResultsByMode(ctx, query, modeCandidates)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, mode := range modeCandidates {
|
|
||||||
modeValue := mode
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
results, err := s.allAnimeClient.Search(ctx, title, modeValue)
|
|
||||||
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
close(searchCh)
|
|
||||||
|
|
||||||
for result := range searchCh {
|
|
||||||
if result.Err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resultsByMode[result.Mode] = result.Results
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mode := range modeCandidates {
|
for _, mode := range modeCandidates {
|
||||||
for _, result := range resultsByMode[mode] {
|
for _, result := range resultsByMode[mode] {
|
||||||
@@ -401,7 +381,6 @@ func (s *Service) resolveShow(ctx context.Context, malID int, title string) (str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(title) != "" {
|
|
||||||
for _, mode := range modeCandidates {
|
for _, mode := range modeCandidates {
|
||||||
results := resultsByMode[mode]
|
results := resultsByMode[mode]
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
@@ -418,6 +397,86 @@ func (s *Service) resolveShow(ctx context.Context, malID int, title string) (str
|
|||||||
return "", "", errors.New("unable to resolve allanime show")
|
return "", "", errors.New("unable to resolve allanime show")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchShowResultsByMode(ctx context.Context, query string, modeCandidates []string) map[string][]searchResult {
|
||||||
|
resultsByMode := make(map[string][]searchResult, len(modeCandidates))
|
||||||
|
searchCh := make(chan searchModeResult, len(modeCandidates))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, mode := range modeCandidates {
|
||||||
|
modeValue := mode
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results, err := s.allAnimeClient.Search(ctx, query, modeValue)
|
||||||
|
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(searchCh)
|
||||||
|
|
||||||
|
for result := range searchCh {
|
||||||
|
if result.Err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsByMode[result.Mode] = result.Results
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultsByMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTitleSearchQueries(titleCandidates []string) []string {
|
||||||
|
queries := make([]string, 0, len(titleCandidates)*4)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
add := func(raw string) {
|
||||||
|
normalized := normalizeSearchQuery(raw)
|
||||||
|
if normalized == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToLower(normalized)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
queries = append(queries, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range titleCandidates {
|
||||||
|
normalized := normalizeSearchQuery(candidate)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
add(normalized)
|
||||||
|
add(strings.ReplaceAll(normalized, "+", " "))
|
||||||
|
|
||||||
|
withoutApostrophes := strings.NewReplacer("'", "", "’", "", "`", "").Replace(normalized)
|
||||||
|
add(withoutApostrophes)
|
||||||
|
add(strings.ReplaceAll(withoutApostrophes, "+", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSearchQuery(raw string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyTitle(values []string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
if normalized != "" {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
|
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
|
||||||
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ templ EpisodeItem(episode jikan.Episode, currentEpisode string, animeID int) {
|
|||||||
|
|
||||||
templ VideoPlayer(data WatchPageData) {
|
templ VideoPlayer(data WatchPageData) {
|
||||||
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
|
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
|
||||||
|
{{ hasDub := modeAvailable(data.AvailableModes, "dub") }}
|
||||||
|
{{ hasSub := modeAvailable(data.AvailableModes, "sub") }}
|
||||||
<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-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||||
@@ -286,12 +288,28 @@ templ VideoPlayer(data WatchPageData) {
|
|||||||
<span data-time class="text-base text-white tabular-nums">00:00 / 00:00</span>
|
<span data-time class="text-base text-white tabular-nums">00:00 / 00:00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button data-mode-dub class="flex h-10 w-10 items-center justify-center text-white" title="Dub">
|
<button
|
||||||
|
data-mode-dub
|
||||||
|
class={
|
||||||
|
"flex h-10 w-10 items-center justify-center text-white",
|
||||||
|
templ.KV("opacity-50 cursor-not-allowed", !hasDub),
|
||||||
|
}
|
||||||
|
title={ modeButtonTitle("Dub", hasDub) }
|
||||||
|
disabled?={ !hasDub }
|
||||||
|
>
|
||||||
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M6 9h6M6 15h4M12 9v6M17 7.5c2.2 2 2.2 7 0 9M19.2 5.5c3.4 3.2 3.4 10 0 13" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<path d="M6 9h6M6 15h4M12 9v6M17 7.5c2.2 2 2.2 7 0 9M19.2 5.5c3.4 3.2 3.4 10 0 13" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button data-mode-sub class="flex h-10 w-10 items-center justify-center text-white" title="Sub">
|
<button
|
||||||
|
data-mode-sub
|
||||||
|
class={
|
||||||
|
"flex h-10 w-10 items-center justify-center text-white",
|
||||||
|
templ.KV("opacity-50 cursor-not-allowed", !hasSub),
|
||||||
|
}
|
||||||
|
title={ modeButtonTitle("Sub", hasSub) }
|
||||||
|
disabled?={ !hasSub }
|
||||||
|
>
|
||||||
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="white" stroke-width="1.85" fill="none"/>
|
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="white" stroke-width="1.85" fill="none"/>
|
||||||
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||||
@@ -361,3 +379,21 @@ func canGoNextEpisode(currentEpisode string, totalEpisodes int) bool {
|
|||||||
}
|
}
|
||||||
return episodeID < totalEpisodes
|
return episodeID < totalEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func modeAvailable(modes []string, mode string) bool {
|
||||||
|
for _, value := range modes {
|
||||||
|
if value == mode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeButtonTitle(label string, enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
return label + " unavailable for this episode"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user