fix: render segment overrides and skip progress

This commit is contained in:
2026-05-22 16:34:26 +02:00
parent c5c15cdabc
commit 51355a4dbc
7 changed files with 73 additions and 58 deletions

View File

@@ -63,9 +63,10 @@ type SeasonEntry struct {
} }
type SkipSegment struct { type SkipSegment struct {
Type string `json:"type"` Type string `json:"type"`
Start float64 `json:"start"` Start float64 `json:"start"`
End float64 `json:"end"` End float64 `json:"end"`
Source string `json:"source,omitempty"`
} }
type ProviderStream struct { type ProviderStream struct {

View File

@@ -146,6 +146,11 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
if u, ok := user.(*domain.User); ok { if u, ok := user.(*domain.User); ok {
userID = u.ID userID = u.ID
} }
if userID == "" {
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
c.Status(http.StatusUnauthorized)
return
}
var req struct { var req struct {
MalID int64 `json:"mal_id"` MalID int64 `json:"mal_id"`

View File

@@ -363,63 +363,50 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
return []domain.SkipSegment{} return []domain.SkipSegment{}
} }
segments := []domain.SkipSegment{}
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode)) endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err == nil {
return []domain.SkipSegment{} req.Header.Set("User-Agent", useragent.Generic)
} if resp, err := s.httpClient.Do(req); err == nil {
req.Header.Set("User-Agent", useragent.Generic) defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusOK {
if body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)); err == nil {
type resultItem struct {
SkipType string `json:"skip_type"`
Interval struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
} `json:"interval"`
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
resp, err := s.httpClient.Do(req) var parsed apiResponse
if err != nil { if err := json.Unmarshal(body, &parsed); err == nil && parsed.Found && len(parsed.Result) > 0 {
return []domain.SkipSegment{} segments = make([]domain.SkipSegment, 0, len(parsed.Result))
} for _, r := range parsed.Result {
defer func() { _ = resp.Body.Close() }() skipType := strings.ToLower(r.SkipType)
switch skipType {
if resp.StatusCode != http.StatusOK { case "op":
return []domain.SkipSegment{} skipType = "opening"
} case "ed":
skipType = "ending"
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)) }
if err != nil { segments = append(segments, domain.SkipSegment{
return []domain.SkipSegment{} Type: skipType,
} Start: r.Interval.StartTime,
End: r.Interval.EndTime,
type resultItem struct { Source: "aniskip",
SkipType string `json:"skip_type"` })
Interval struct { }
StartTime float64 `json:"start_time"` }
EndTime float64 `json:"end_time"` }
} `json:"interval"` }
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
var parsed apiResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return []domain.SkipSegment{}
}
if !parsed.Found || len(parsed.Result) == 0 {
return []domain.SkipSegment{}
}
segments := make([]domain.SkipSegment, 0, len(parsed.Result))
for _, r := range parsed.Result {
skipType := strings.ToLower(r.SkipType)
switch skipType {
case "op":
skipType = "opening"
case "ed":
skipType = "ending"
} }
segments = append(segments, domain.SkipSegment{
Type: skipType,
Start: r.Interval.StartTime,
End: r.Interval.EndTime,
})
} }
epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64) epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64)
@@ -438,7 +425,12 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
default: default:
continue continue
} }
overrideByType[t] = domain.SkipSegment{Type: t, Start: o.StartTime, End: o.EndTime} overrideByType[t] = domain.SkipSegment{
Type: t,
Start: o.StartTime,
End: o.EndTime,
Source: "override",
}
} }
if len(overrideByType) > 0 { if len(overrideByType) > 0 {
merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType)) merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType))

View File

@@ -22,6 +22,8 @@ const sendBeacon = (payload: string) => {
*/ */
export const saveProgress = async (): Promise<void> => { export const saveProgress = async (): Promise<void> => {
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return; if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
// progress is user-scoped; avoid spamming 401s for anonymous sessions
if (!document.cookie.includes('mal_session=')) return;
const episode = Number.parseInt(state.currentEpisode, 10); const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return; if (!episode) return;
@@ -60,6 +62,8 @@ const scheduleProgressSave = (): void => {
*/ */
export const markEpisodeTransition = (episodeNumber: number): void => { export const markEpisodeTransition = (episodeNumber: number): void => {
if (!state.malID || !episodeNumber) return; if (!state.malID || !episodeNumber) return;
// progress is user-scoped; avoid sending beacons for anonymous sessions
if (!document.cookie.includes('mal_session=')) return;
if (state.progressSaveTimer !== undefined) { if (state.progressSaveTimer !== undefined) {
window.clearTimeout(state.progressSaveTimer); window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined; state.progressSaveTimer = undefined;
@@ -102,6 +106,7 @@ export const setupProgress = (): void => {
// save on page close // save on page close
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return; if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
if (!document.cookie.includes('mal_session=')) return;
const episode = Number.parseInt(state.currentEpisode, 10); const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return; if (!episode) return;
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime))); sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));

View File

@@ -151,10 +151,16 @@ export const setupSegmentEditor = (): void => {
if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro'; if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro';
return t !== 'op' && t !== 'opening' && t !== 'intro'; return t !== 'op' && t !== 'opening' && t !== 'intro';
}); });
state.parsedSegments.push({ type: normalizedType, start: startTime, end: endTime }); state.parsedSegments.push({
type: normalizedType,
start: startTime,
end: endTime,
source: 'override',
});
resolveActiveSegments(); resolveActiveSegments();
renderSegments(); renderSegments();
window.showToast?.({ message: 'Segment saved.' });
close(); close();
} catch { } catch {
setError('Failed to save segment.'); setError('Failed to save segment.');

View File

@@ -27,6 +27,7 @@ export const resolveActiveSegments = (): void => {
state.activeSegments = state.parsedSegments.filter(s => { state.activeSegments = state.parsedSegments.filter(s => {
const t = normalizeType(s.type); const t = normalizeType(s.type);
if (!t) return false; if (!t) return false;
const isOverride = (s.source || '').toLowerCase() === 'override';
const len = s.end - s.start; const len = s.end - s.start;
// duration filter // duration filter
@@ -34,6 +35,9 @@ export const resolveActiveSegments = (): void => {
// bounds check // bounds check
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false; if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false;
// User overrides should render even if they don't fit AniSkip's usual OP/ED heuristics.
if (isOverride) return true;
// intro: starts early, before 50% of video // intro: starts early, before 50% of video
if (t === 'op') { if (t === 'op') {
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5; return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;

View File

@@ -16,6 +16,7 @@ export interface SkipSegment {
type: string; // 'op' or 'ed' type: string; // 'op' or 'ed'
start: number; start: number;
end: number; end: number;
source?: string;
} }
// parsed subtitle cue from VTT // parsed subtitle cue from VTT
@@ -37,6 +38,7 @@ export interface ActiveSegment {
type: string; type: string;
start: number; start: number;
end: number; end: number;
source?: string;
} }
// timeline range (handles seekable ranges in live streams) // timeline range (handles seekable ranges in live streams)