From c41016185cb54974a0711e40931e8ccb402b6f5f Mon Sep 17 00:00:00 2001 From: Yuri Chukhlib Date: Sun, 23 Aug 2026 12:22:03 +0200 Subject: [PATCH] Fix description timestamp links seeking to wrong position for out-of-range values (#966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Yattee/Utilities/DescriptionText.swift | 28 +++++++---- YatteeTests/DescriptionTextTests.swift | 67 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 YatteeTests/DescriptionTextTests.swift diff --git a/Yattee/Utilities/DescriptionText.swift b/Yattee/Utilities/DescriptionText.swift index 4fc6d231..f68c1946 100644 --- a/Yattee/Utilities/DescriptionText.swift +++ b/Yattee/Utilities/DescriptionText.swift @@ -56,12 +56,13 @@ enum DescriptionText { } let timestampString = String(text[range]) - let seconds = parseTimestamp(timestampString) - - if let url = URL(string: "yattee-seek://\(seconds)") { - attributedString[attributedRange].link = url - attributedString[attributedRange].foregroundColor = linkColor + guard let seconds = parseTimestamp(timestampString), + let url = URL(string: "yattee-seek://\(seconds)") else { + continue } + + 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. - 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) } switch components.count { 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 - 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: - return 0 + return nil } } diff --git a/YatteeTests/DescriptionTextTests.swift b/YatteeTests/DescriptionTextTests.swift new file mode 100644 index 00000000..219cc3f6 --- /dev/null +++ b/YatteeTests/DescriptionTextTests.swift @@ -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) + } +}