Fix description timestamp links seeking to wrong position for out-of-range values (#966)

DescriptionText.parseTimestamp turned a matched timestamp string into a
clickable seek link by computing seconds from its colon-separated parts
with no range validation. The link regex (\d{1,2}:\d{2}(?::\d{2})?)
matches strings that are not valid clock positions, such as 1:99 or
0:60, and parseTimestamp happily computed 1*60+99 = 159 for 1:99 — so
tapping such a link seeked the player to 2:39 instead of being ignored.

This also diverged from ChapterParser, which rejects seconds/minutes
>= 60; the same timestamp string could be dropped as a chapter but
still seek as a description link.

- Validate seconds < 60 in the MM:SS branch and minutes < 60, seconds
  < 60 in the H:MM:SS branch; return nil otherwise
- Change parseTimestamp to return Int? and skip building the seek link
  for nil, so out-of-range matches are left as plain text
- Add DescriptionTextTests with valid, boundary, and out-of-range cases
This commit is contained in:
Yuri Chukhlib
2026-08-23 12:22:03 +02:00
committed by GitHub
parent 3d2544b22d
commit c41016185c
2 changed files with 86 additions and 9 deletions

View File

@@ -56,12 +56,13 @@ enum DescriptionText {
} }
let timestampString = String(text[range]) let timestampString = String(text[range])
let seconds = parseTimestamp(timestampString) guard let seconds = parseTimestamp(timestampString),
let url = URL(string: "yattee-seek://\(seconds)") else {
if let url = URL(string: "yattee-seek://\(seconds)") { continue
attributedString[attributedRange].link = url
attributedString[attributedRange].foregroundColor = linkColor
} }
attributedString[attributedRange].link = url
attributedString[attributedRange].foregroundColor = linkColor
} }
} }
@@ -69,15 +70,24 @@ enum DescriptionText {
} }
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds. /// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
static func parseTimestamp(_ timestamp: String) -> Int { /// Returns nil when a component is out of its valid range (e.g. `1:99`,
/// `0:60`, `1:99:30`); those strings are matched by the link regex above
/// but are not valid clock positions, so they must not become seek links.
/// This mirrors the range validation in `ChapterParser`.
static func parseTimestamp(_ timestamp: String) -> Int? {
let components = timestamp.split(separator: ":").compactMap { Int($0) } let components = timestamp.split(separator: ":").compactMap { Int($0) }
switch components.count { switch components.count {
case 2: // MM:SS case 2: // MM:SS
return components[0] * 60 + components[1] let seconds = components[1]
guard seconds < 60 else { return nil }
return components[0] * 60 + seconds
case 3: // H:MM:SS case 3: // H:MM:SS
return components[0] * 3600 + components[1] * 60 + components[2] let minutes = components[1]
let seconds = components[2]
guard minutes < 60, seconds < 60 else { return nil }
return components[0] * 3600 + minutes * 60 + seconds
default: default:
return 0 return nil
} }
} }

View File

@@ -0,0 +1,67 @@
//
// DescriptionTextTests.swift
// YatteeTests
//
// Unit tests for description-text timestamp parsing.
//
import Testing
import Foundation
@testable import Yattee
@Suite("DescriptionText Timestamp Parsing")
struct DescriptionTextTimestampTests {
// MARK: - Valid Timestamps
@Test("Parses M:SS format")
func parseMSS() {
#expect(DescriptionText.parseTimestamp("0:00") == 0)
#expect(DescriptionText.parseTimestamp("5:30") == 330)
}
@Test("Parses MM:SS format")
func parseMMSS() {
#expect(DescriptionText.parseTimestamp("00:00") == 0)
#expect(DescriptionText.parseTimestamp("12:45") == 765)
#expect(DescriptionText.parseTimestamp("59:59") == 3599)
}
@Test("Parses H:MM:SS format")
func parseHMMSS() {
#expect(DescriptionText.parseTimestamp("1:23:45") == 5025)
#expect(DescriptionText.parseTimestamp("01:23:45") == 5025)
}
@Test("Parses long minutes in MM:SS form")
func parseLongMinutes() {
// A 99-minute position is valid for a long video (2-digit minutes).
#expect(DescriptionText.parseTimestamp("99:59") == 5999)
}
// MARK: - Out-of-Range Rejection (regression)
@Test("Rejects seconds >= 60 in MM:SS form")
func rejectsInvalidSeconds() {
// Previously returned components[0]*60 + components[1] = 60 and 159,
// seeking the player to the wrong position.
#expect(DescriptionText.parseTimestamp("0:60") == nil)
#expect(DescriptionText.parseTimestamp("1:99") == nil)
#expect(DescriptionText.parseTimestamp("5:75") == nil)
}
@Test("Rejects minutes or seconds >= 60 in H:MM:SS form")
func rejectsInvalidHoursMinutesSeconds() {
#expect(DescriptionText.parseTimestamp("1:99:30") == nil)
#expect(DescriptionText.parseTimestamp("1:30:60") == nil)
#expect(DescriptionText.parseTimestamp("2:60:00") == nil)
}
// MARK: - Boundary
@Test("Accepts the 59 boundary in every field")
func acceptsBoundary() {
#expect(DescriptionText.parseTimestamp("59:59") == 3599)
#expect(DescriptionText.parseTimestamp("1:59:59") == 7199)
}
}