Files
RSSHub/lib/utils/parse-date.test.ts
Yufan You a442c97770 feat(route/youtube): live streams with upcoming and past ones (#23175)
* fix(utils): parse abbreviated relative date units

YouTube serves a compact metadata variant to some sessions, where a video
published a year ago reads `1y ago` instead of `1 year ago`. Neither `y`,
`w` nor `mo` was recognised, so those strings fell through to the plain
Day.js parse and came back as `Invalid Date`.

`mo` needs a lookahead on the minutes pattern too: the bare `m` matched
the `mo` of a month, so `11 months ago` resolved to eleven months *and*
eleven minutes ago, and `3mo ago` to three minutes ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(route/youtube): add live streams

`/youtube/live` searches for ongoing broadcasts through the Data API, so
there is no way to follow a channel's schedule or its past streams. Read
the channel's Live tab instead, which lists all three, and let the route
params pick which of them end up in the feed. No API key needed.

A stream is classified by its thumbnail badge: an ongoing one carries a
`LIVE` badge and a scheduled one an `Upcoming` badge. The absence of a
duration badge is deliberately not used as the signal, because YouTube
groups durations past a thousand hours, e.g. `20,772:51:34` — the same
reason the duration attachment now strips separators before summing.

Scheduled streams have no publish date, so they take the time they are
scheduled for. YouTube renders it in the time zone youtubei.js reports,
which is the local one, hence the plain local parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(utils): hoist parse-date test helper to module scope

`p` captured nothing from the `describe` scope, which tripped
`unicorn/consistent-function-scoping`. Moving it out also lets its
parameters be derived from `parseRelativeDate` instead of `any[]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(route/youtube): add stream descriptions

The lockups on the Live tab carry only a title, so every item in
`/youtube/streams` had an empty body. Add `includeDescription`, which
fills it from the player response. That is one request per stream, hence
a default of false and only the streams that survive the state filter
being fetched.

A stream can be unplayable, e.g. a members-only one, so a failed lookup
falls back to an empty description instead of taking the whole feed
down. The cached value is wrapped in an object because an empty string
comes back out of `cache.tryGet` as a literal `""`, and its expiration
is deliberately not renewed on a hit, so an edited description still
reaches a feed that is polled steadily.

`isIncluded` becomes `isEnabled` to give the new parameter a default of
false while the `include*` ones keep parsing exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(route/youtube): categorize streams instead of filtering them

The `includeLive` / `includeUpcoming` / `includeCompleted` route params
reimplemented, per route, what the common `filter_category` params
already do. Drop them and expose the state that drove them as the item
category, so `/youtube/streams/@GawrGura?filterout_category=completed`
replaces `/youtube/streams/@GawrGura/includeCompleted=false`.

The category lives in `getStreamsByChannelId` rather than in the shared
`lockupViewToItem`, which also serves the Videos tab and playlists where
an uploaded video is not a `completed` stream.

With the filtering gone, `allowEmpty` goes too. It only ever covered a
feed emptied by those params: a channel that has never streamed has no
Live tab at all, so `getLiveStreams()` throws rather than returning
nothing, and the empty check runs before the `filter_category` pass. An
empty list now means the lockup parsing broke, which should surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:30:04 +08:00

207 lines
8.0 KiB
TypeScript

import dayjs from 'dayjs';
import weekday from 'dayjs/plugin/weekday.js';
import MockDate from 'mockdate';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseRelativeDate } from './parse-date';
dayjs.extend(weekday);
const p = (...args: Parameters<typeof parseRelativeDate>) => parseRelativeDate(...args).getTime();
describe('parseRelativeDate', () => {
// === CONSTANTS ===
const second = 1000;
const minute = 60 * second;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
// === BASE DATE SETUP: 2026-02-02 (Monday) ===
const BASE_STR = '2026-02-02T12:00:00';
const NOW_TIMESTAMP = new Date(BASE_STR).getTime();
// Derived Timestamps
const TODAY_START = new Date('2026-02-02T00:00:00').getTime();
const YESTERDAY_START = TODAY_START - day;
const TOMORROW_START = TODAY_START + day;
// Strict Past Logic Expectations (Reference is Mon Feb 02)
const PREVIOUS_MONDAY = TODAY_START - week; // Jan 26 (Last week)
const PREVIOUS_WEDNESDAY = TODAY_START + 2 * day - week; // Jan 28 (Last week)
const LAST_SUNDAY = new Date('2026-02-01T00:00:00').getTime(); // Yesterday (Feb 01)
const LAST_FRIDAY = new Date('2026-01-30T00:00:00').getTime(); // Last Friday (Jan 30)
beforeEach(() => {
MockDate.set(NOW_TIMESTAMP);
});
afterEach(() => {
MockDate.reset();
});
/**
* Category 1: Immediate & Fuzzy Semantics
*/
describe('Immediate & Fuzzy Semantics', () => {
it('handles "Just now" / "刚刚"', () => {
const expected = NOW_TIMESTAMP - 3 * second;
expect(p('Just now')).toBe(expected);
expect(p('just now')).toBe(expected);
expect(p('刚刚')).toBe(expected);
});
it('handles vague quantifiers (x / 几)', () => {
// "几" maps to 3
expect(p('几分钟前')).toBe(NOW_TIMESTAMP - 3 * minute);
expect(p('幾分鐘前')).toBe(NOW_TIMESTAMP - 3 * minute);
expect(p('数秒前')).toBe(NOW_TIMESTAMP - 3 * second);
// "x" maps to 3
expect(p('x days ago')).toBe(NOW_TIMESTAMP - 3 * day);
expect(p('X seconds ago')).toBe(NOW_TIMESTAMP - 3 * second);
// "a/an" maps to 1
expect(p('a minute ago')).toBe(NOW_TIMESTAMP - 1 * minute);
});
});
/**
* Category 2: Relative Duration (Ago / In)
*/
describe('Relative Duration', () => {
it('handles past (ago / 前)', () => {
expect(p('10m ago')).toBe(NOW_TIMESTAMP - 10 * minute);
expect(p('2 hours ago')).toBe(NOW_TIMESTAMP - 2 * hour);
expect(p('10秒前')).toBe(NOW_TIMESTAMP - 10 * second);
});
it('handles future (in / later / 后)', () => {
expect(p('in 10m')).toBe(NOW_TIMESTAMP + 10 * minute);
expect(p('2 hours later')).toBe(NOW_TIMESTAMP + 2 * hour);
expect(p('10分钟后')).toBe(NOW_TIMESTAMP + 10 * minute);
expect(p('10 分鐘後')).toBe(NOW_TIMESTAMP + 10 * minute);
});
it('handles abbreviated units', () => {
expect(p('1y ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(1, 'year').valueOf());
expect(p('3mo ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(3, 'month').valueOf());
expect(p('2w ago')).toBe(NOW_TIMESTAMP - 2 * week);
expect(p('5d ago')).toBe(NOW_TIMESTAMP - 5 * day);
});
it('does not read the "m" of a month as a minute', () => {
expect(p('11 months ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(11, 'month').valueOf());
expect(p('11mo ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(11, 'month').valueOf());
});
it('handles mixed units', () => {
expect(p('1d 1h ago')).toBe(NOW_TIMESTAMP - (day + hour));
});
});
/**
* Category 3: Semantic Keywords & Strict Past Logic
* Reference: Today is Monday, Feb 02.
*/
describe('Semantic Keywords & Weekdays', () => {
it('handles Today/Yesterday/Tomorrow', () => {
expect(p('Today')).toBe(TODAY_START);
expect(p('Yesterday')).toBe(YESTERDAY_START);
expect(p('Tomorrow')).toBe(TOMORROW_START);
});
it('handles "Monday" (Strict Past: Today is Mon -> Last Mon)', () => {
// Strict past rule: If input weekday is Same as today, go back 1 week.
expect(p('Monday')).toBe(PREVIOUS_MONDAY);
expect(p('周一')).toBe(PREVIOUS_MONDAY);
expect(p('星期一')).toBe(PREVIOUS_MONDAY);
});
it('handles "Monday 3pm" (Strict Past)', () => {
expect(p('Monday 3pm')).toBe(PREVIOUS_MONDAY + 15 * hour);
expect(p('周一 15:00')).toBe(PREVIOUS_MONDAY + 15 * hour);
});
it('handles "Wednesday" (Strict Past: Wed is Future -> Last Wed)', () => {
// Strict past rule: If input weekday is Future, go back 1 week.
// Today is Mon Feb 02. Wed is Feb 04 (Future) -> Back 1 week -> Jan 28.
expect(p('Wednesday')).toBe(PREVIOUS_WEDNESDAY);
expect(p('周三')).toBe(PREVIOUS_WEDNESDAY);
});
it.each([
['Tuesday', '2026-01-27T00:00:00'],
['Tue', '2026-01-27T00:00:00'],
['Thursday', '2026-01-29T00:00:00'],
['Thu', '2026-01-29T00:00:00'],
['Tuesday 3pm', '2026-01-27T15:00:00'],
['Thu 10:00', '2026-01-29T10:00:00'],
])('parses %s as a past weekday instead of tomorrow', (input, expected) => {
expect(p(input)).toBe(new Date(expected).getTime());
});
it.each(['Tomorrow', 't'])('preserves %s as tomorrow with optional time', (input) => {
expect(p(input)).toBe(TOMORROW_START);
expect(p(`${input}3pm`)).toBe(TOMORROW_START + 15 * hour);
expect(p(`${input} 10:00`)).toBe(TOMORROW_START + 10 * hour);
});
it('handles "Sunday" (Past: Sun is Yesterday -> Feb 01)', () => {
// Sunday (Feb 01) is strictly before Monday (Feb 02).
expect(p('Sunday')).toBe(LAST_SUNDAY);
});
it('handles "Friday" (Past: Fri is Jan 30)', () => {
expect(p('Friday')).toBe(LAST_FRIDAY);
});
});
/**
* Category 4: Contextual & Combined Expressions
*/
describe('Contextual & Formatted Time', () => {
it('handles sticky AM/PM', () => {
expect(p('Today 3pm')).toBe(TODAY_START + 15 * hour);
expect(p('Yesterday 10pm')).toBe(YESTERDAY_START + 22 * hour);
});
it('handles spaced AM/PM', () => {
expect(p('Today 3 pm')).toBe(TODAY_START + 15 * hour);
});
it('handles Chinese modifiers (Morning/Evening)', () => {
// "明早" -> Tomorrow 8am
expect(p('明早8点')).toBe(TOMORROW_START + 8 * hour);
// "昨晚" -> Yesterday 8pm (20:00)
expect(p('昨晚8点')).toBe(YESTERDAY_START + 20 * hour);
// "周五下午3点" -> Last Friday 15:00
expect(p('周五下午3点')).toBe(LAST_FRIDAY + 15 * hour);
// https://github.com/DIYgod/RSSHub/issues/20878
expect(p('昨天 23:01')).toBe(YESTERDAY_START + 23 * hour + 1 * minute);
});
it('handles 12am / 12pm edge cases', () => {
expect(p('Today 12pm')).toBe(TODAY_START + 12 * hour); // Noon
expect(p('Today 12am')).toBe(TODAY_START); // Midnight
});
});
/**
* Category 5: Edge Cases & Fallback
*/
describe('Fallback', () => {
it('passes formatting options to dayjs', () => {
const str = '05/02/2026';
const format = 'DD/MM/YYYY'; // Feb 5th
const expected = new Date('2026-02-05T00:00:00').getTime();
expect(p(str, format)).toBe(expected);
});
it('returns raw absolute dates', () => {
const raw = '2026-01-01T00:00:00';
expect(p(raw)).toBe(new Date(raw).getTime());
});
});
});