diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58006d8..c2830f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: - name: Run deterministic local gate env: + FIREFOX_BINARY: ${{ steps.firefox.outputs.firefox-path }} FIREFOX_LANGPACK_DIR: ${{ runner.temp }}/firefox-langpacks run: npm run test:local:offline diff --git a/CHANGELOG.md b/CHANGELOG.md index c7478fd..88dd493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to YT re:Watch will be documented in this file. +## [5.2.1] - 2026-09-04 + +### Added + +- Added Channels sorting by name, date followed, latest upload, activity, and last + checked, with a direction toggle and a remembered selection. Defaults to name A–Z. + +### Fixed + +- Fixed subscription scanning getting stuck behind low-activity channels and + repeatedly blinking its status without downloading videos. +- Made **Reload videos** check all followed channels outside failure backoff or + an active scan, update the visible list, and report failed or deferred checks. +- Debounced feed and popup search by 300 ms, with immediate Enter and clear actions. +- Repaired obsolete 30-day RSS error schedules when the feed scheduler starts. +- Recognize frequent uploads throughout the day, promote busy channels immediately, + and require a quiet period before gradually lowering their activity class. +- Added **Check for new videos** to each followed channel, with updated activity, + next-check timing, and results. **Log** now reads the latest stored scan records. + +### Security + +- Updated development dependencies `fast-uri` to 3.1.7 and `@humanfs/node` to + 0.16.8, addressing five Dependabot alerts in URI handling and recursive file copying. + ## [5.2.0] - 2026-09-01 ### Added diff --git a/README.md b/README.md index 7b734ad..8ce94a8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ YT re:Watch keeps its history inside your browser profile. It works while you sw - **Experimental AI-label handling:** optionally badges, dims, or hides videos that YouTube itself discloses as made with AI. - **Local channel follows:** follow or unfollow channels inside re:Watch without changing a YouTube account subscription. - **Local feed:** public channel RSS builds a cached Home, chronological Subscriptions, and Shorts inventory. -- **Channels management:** review followed channels, import subscriptions from Google Takeout, and explicitly restore channels kept in the Ignored list after a local unfollow. +- **Channels management:** sort followed channels, check individual channels for new uploads, review scan logs, import subscriptions from Google Takeout, and restore channels kept in the Ignored list after a local unfollow. - **Local search:** searches saved history, cached feed videos, and channels without sending the typed query to YouTube. - **History, Watch Later, and local playlists:** keep viewing records, saved YouTube playlist links, and extension-managed playlists in one full-page interface. - **Private analytics:** watch time, completion, hourly/daily activity, top channels, skipped channels, and unfinished long videos are calculated locally. @@ -47,7 +47,7 @@ The full feed page contains: | **Analytics** | Locally calculated watch statistics and insights | | **Settings** | Appearance, retention, feed timing, imports, backup/restore, and reset controls | -Home and Subscriptions render stable 50-card pages as you scroll. Subscriptions retains its selected upload-date or detection-date ordering through **Reload view** and **Show**. Checking for new videos is separate from reloading the current local view, so a background scan does not unexpectedly replace or reorder visible cards. When uploads are found, re:Watch offers a deliberate **Show** action. +Home and Subscriptions render stable 50-card pages as you scroll. Subscriptions retains its selected upload-date or detection-date ordering through **Reload videos**, **Refresh**, and **Show**. **Reload videos** checks followed channels and updates the visible list; channels in failure backoff or already being scanned are deferred. **Refresh** redraws saved records. Background discoveries appear through a **Show** action so they do not unexpectedly replace visible cards. The compact popup and full-page interface show the installed extension version beside the re:Watch icon, making it easier to identify the active build when reporting a problem. @@ -66,6 +66,10 @@ You can add channels by: A local unfollow stops future scans and removes that channel's cached feed inventory without deleting independent watch history. re:Watch records the choice locally. A later import reports that channel as ignored instead of silently following it again; the user can review it in **Channels → Ignored** and explicitly follow it again. +Channels defaults to **Name A–Z**. Choose **Date followed**, **Latest upload**, **Activity**, or **Last checked**, and use the direction button to reverse the order. The selection is remembered across page reloads. **Check for new videos** checks only that channel and updates its activity and next-check time; **Log** opens its latest recorded RSS attempts. [See the Channels guide](./docs/detailed_guide.md#channels) for sorting details and deferred checks. + +Activity is recalculated from observed uploads after successful checks. Strong recent activity promotes a channel promptly; downgrades require a quiet period. Feed and popup search wait 300 ms after typing stops, while Enter and clearing the field act immediately. + The feed is best-effort discovery rather than a mirror of a signed-in YouTube account. Public RSS may not represent membership, age-restricted, region-restricted, removed, or otherwise unavailable videos. YouTube applies its own access rules when a video is opened. ## Playlists in v5.2 diff --git a/docs/assets/guide/feed-channels.png b/docs/assets/guide/feed-channels.png index 140ca24..4e846f0 100644 Binary files a/docs/assets/guide/feed-channels.png and b/docs/assets/guide/feed-channels.png differ diff --git a/docs/assets/guide/feed-home.png b/docs/assets/guide/feed-home.png index 6ae905f..2ead1ee 100644 Binary files a/docs/assets/guide/feed-home.png and b/docs/assets/guide/feed-home.png differ diff --git a/docs/assets/guide/feed-subscriptions.png b/docs/assets/guide/feed-subscriptions.png index c6802a2..cfedc92 100644 Binary files a/docs/assets/guide/feed-subscriptions.png and b/docs/assets/guide/feed-subscriptions.png differ diff --git a/docs/changelog.md b/docs/changelog.md index a94ad63..97ed41f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,20 @@ # Changelog +## v5.2.1 — 4 September 2026 + +- Added Channels sorting by name, date followed, latest upload, activity, and last + checked. The selected order and direction are remembered; the default is name A–Z. +- Fixed stalled subscription downloads and continuously blinking scan status. +- **Reload videos** now checks followed channels and updates the visible list, + while preserving failure backoff and reporting failed or deferred checks. +- Added a 300 ms debounce to feed and popup search. Enter and clearing remain immediate. +- Repair old 30-day RSS error delays and recognize channels with frequent daily + uploads, promoting activity promptly and downgrading only after a quiet period. +- Added a per-channel **Check for new videos** action in Channels. Activity and + scheduling refresh after a check, and **Log** always loads current scan records. +- Updated development dependencies `fast-uri` to 3.1.7 and `@humanfs/node` to + 0.16.8 to address five Dependabot alerts in URI handling and recursive file copying. + ## v5.2.0 — 1 September 2026 - Added experimental **AI-labeled video handling** with Off, Badge, Dim, and diff --git a/docs/code-notes.md b/docs/code-notes.md index f382861..58bb21f 100644 --- a/docs/code-notes.md +++ b/docs/code-notes.md @@ -80,6 +80,8 @@ Files named `feed*.js` power the extension feed page: - `feed-subscription-import.js`, `feed-subscriptions-view.js`, `feed-subscribe-results.js`, and `feed-channel-view.js`: canonical local subscription acquisition and channel surfaces. + `feed-subscriptions-view.js` also owns the remembered Channels sort, per-channel + manual checks, and fresh reads of the RSS attempt log. - `feed-playlists-view.js`, `feed-playlist-import.js`, `feed-history-view.js`, `feed-analytics.js`, `feed-settings.js`, and `feed-backup.js`: individual feed sections and profile portability. diff --git a/docs/detailed_guide.md b/docs/detailed_guide.md index 6d27cbc..13d08d3 100644 --- a/docs/detailed_guide.md +++ b/docs/detailed_guide.md @@ -62,11 +62,11 @@ Home uses stable 50-card pages. Moving between pages does not duplicate cards or Subscriptions is the complete inventory of regular cached uploads. Choose upload-date or detection-date ordering; the selection remains active through -**Reload** and **Show**. The view also uses stable 50-card pages. +**Reload videos**, **Refresh**, and **Show**. The view also uses stable 50-card pages. ### Reload videos, Refresh, and Show -- **Reload videos** scans eligible followed channels and updates cached feed records. The status beside it reports progress, scheduling, and outcomes. +- **Reload videos** checks followed channels and updates the visible list, even when their normal check time is still in the future. It includes low-activity channels and preserves the selected ordering. Channels in failure backoff or already being scanned are deferred; the status reports checked, failed, and deferred counts. - **Refresh** renders current local storage again without starting a feed request. - **Show** reloads the complete Subscriptions inventory after a scan or subscription-import handoff and retains its selected ordering. @@ -76,6 +76,8 @@ The interface renders cached data before initialization work completes, so an ex Search matches locally saved history and cached feed records. It does not send the query to YouTube and cannot discover a video that the extension has never stored. +Feed and popup searches run after a 300 ms pause in typing. Press Enter to search immediately. Clearing the field takes effect immediately and cancels a pending search. + ### Card actions Video menus provide local actions such as more or less from a channel, hide, subscribe or unsubscribe, Watch Later, and playlist-reference actions where applicable. Following state is matched by canonical YouTube channel identity so the card does not offer **Subscribe** for a channel already followed locally. @@ -124,12 +126,40 @@ The **Watch Later** sidebar view orders saved videos newest first. Each row open ## Channels { #channels } -![The re:Watch Channels tab showing channels followed locally by the extension.](assets/guide/feed-channels.png) +![The re:Watch Channels tab with sorting, per-channel checks, activity, next-check times, and RSS logs.](assets/guide/feed-channels.png) -*Channels manages follows owned by re:Watch, not by the YouTube account.* +*Sort local follows and check each channel's uploads, activity, and scan history.* Follow a channel from a supported YouTube channel/watch surface, a re:Watch video menu, or the Channels controls. You can also import subscriptions in Settings. re:Watch canonicalizes channel IDs and avoids duplicate follows. +### Sort followed channels + +Use **Sort channels** and the direction button above the list. The default is **Name A–Z**, and the selected field and direction survive navigation and page reloads. + +| Sort | Initial direction | Meaning | +| --- | --- | --- | +| Name | A–Z | Channel name, with natural number ordering | +| Date followed | Newest first | When the channel was added to re:Watch | +| Latest upload | Newest first | Most recent upload observed by re:Watch | +| Activity | Most active first | Observed publishing frequency; ties use latest upload first | +| Last checked | Oldest first | Most recent check attempt, including failed attempts; never-checked channels come first | + +Missing upload dates and unknown activity are placed last in either direction. These controls apply to followed channels; the Ignored tab keeps its own list. + +### Check one channel and inspect its log + +Select **Check for new videos** on a channel card. The button shows that the check is running, then the card reports the result. A successful check refreshes its latest upload, recalculates activity, and sets the next check time. Newly discovered uploads are available through **Show**. + +The action checks only that channel and bypasses its normal successful-check interval. Failure backoff and an existing scan can still defer it. Use **Reload videos** in the feed to check all followed channels. + +**Log** loads the latest stored RSS attempts whenever it opens, including timestamps, HTTP status or failure code, and available details. An empty log means no RSS attempts have been recorded yet; it does not confirm a successful check. + +### Activity and next-check timing + +Activity is based on observed uploads, rather than the channel's lifetime video count. A busy recent upload pattern can raise its activity level immediately. Frequent uploads spread throughout a day are recognized even when the RSS sample covers less than a day; a short bulk-upload burst alone does not establish that cadence. Lower activity requires a quiet period and changes gradually. + +Version 5.2.1 also removes obsolete 30-day delays left by older RSS 404 handling when the scheduler starts. It preserves independent failure backoff and running checks. If a frequently publishing channel still looks stale, use **Check for new videos** and review **Log**. + ### Ignored channels Unfollowing an imported/local channel can create a tombstone. Future imports skip that channel instead of silently restoring it. When one or more tombstones exist, an **Ignored** internal tab appears under Channels. diff --git a/docs/faq.md b/docs/faq.md index 728529f..07594a3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -56,15 +56,25 @@ From public RSS uploads cached for channels followed in re:Watch. Home arranges that local inventory; Subscriptions can order it by upload date or by when re:Watch detected it. -### What is the difference between Check, Reload, and Show? +### What is the difference between Reload videos, Refresh, and Show? -- **Check** scans eligible channel feeds and updates local records. -- **Reload** redraws from local storage without starting a scan. +- **Reload videos** checks all followed channels and updates the visible list. It bypasses normal successful-check intervals, but defers channels in failure backoff or already being scanned. Its result reports checked, failed, and deferred counts. +- **Refresh** redraws from local storage without starting a scan. - **Show** reloads the full Subscriptions inventory while retaining its selected upload-date or detection-date order. +For one channel, open **Channels** and select **Check for new videos**. A successful check recalculates its activity and next-check time. **Log** opens its latest recorded RSS attempts. + +### How are channels sorted? + +Channels defaults to **Name A–Z**. You can choose **Date followed**, **Latest upload**, **Activity**, or **Last checked**, and reverse the direction. Your selection is remembered across reloads. Activity ties use the latest upload first; Last checked, oldest first puts never-checked channels first. [Full sorting details](detailed_guide.md#sort-followed-channels). + +### Why can a busy channel look inactive or have an old last-upload date? + +Activity and latest-upload dates reflect uploads re:Watch has observed. They can lag while checks fail or are deferred. **Check for new videos** refreshes the evidence and **Log** shows recent results. Strong recent activity promotes a channel promptly; lowering activity requires a quiet period. Version 5.2.1 also repairs obsolete 30-day delays from older RSS 404 handling when the scheduler starts. + ### Does feed search query YouTube? -No. It searches only local history and cached feed records. +No. It searches only local history and cached feed records. Feed and popup searches wait for a 300 ms pause in typing; Enter and clearing the field take effect immediately. ### How many videos are on a page? diff --git a/docs/index.md b/docs/index.md index 1bcb8ec..574f3dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ YT re:Watch keeps a private, account-independent YouTube history in your browser | Subscriptions | Shows cached subscription videos ordered by upload or detection time | | Shorts | Separates watched Shorts from ordinary history | | Watch Later | Keeps videos explicitly saved for later, independently of a YouTube account | -| Channels | Manages local follows and reviewable ignored-channel records | +| Channels | Sorts local follows, checks individual channels, and shows scan logs and ignored-channel records | | Analytics | Summarizes local activity, watch time, completion, and channel patterns | | Playlist references | Saves links to YouTube playlists without importing their members | | Local playlists | Creates and manages extension-owned playlists without changing YouTube account playlists | @@ -45,16 +45,20 @@ and Settings. The feed renders cached local records first. -- **Reload videos** scans eligible followed channels and updates cached feed records. +- **Reload videos** checks followed channels and updates the visible list, including channels whose normal check is scheduled for later. Failure backoff and checks already in progress are respected; the result reports checked, failed, and deferred channels. - **Refresh** rebuilds the visible view from local storage without starting a network scan. - **Show** reloads the complete subscription inventory after a scan or import handoff while preserving the selected upload-date or detection-date order. +In **Channels**, use **Check for new videos** for a single channel. A successful check updates its upload history, activity, and next-check time. **Log** shows the latest recorded RSS attempts. + Public YouTube RSS feeds provide new uploads. re:Watch does not use OAuth, mutate the user's YouTube subscriptions, or perform remote search. ## Local subscriptions and ignored channels A re:Watch subscription belongs only to the current browser profile. Following or unfollowing a channel does not change the signed-in YouTube account. +Sort followed channels by **Name**, **Date followed**, **Latest upload**, **Activity**, or **Last checked**. The default is Name A–Z; your selected field and direction are remembered. [See the Channels guide](detailed_guide.md#channels) for the ordering rules. + When an imported channel was previously removed, re:Watch keeps a tombstone so later imports do not silently add it again. If such records exist, **Channels → Ignored** appears and lets you restore or permanently forget them. The tab stays hidden when there is nothing to review. ## Playlist behavior diff --git a/docs/roadmap.md b/docs/roadmap.md index ea909f6..698a516 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -129,10 +129,10 @@ assigned to a release version. When a planned release becomes testable, development builds may use semantic pre-release versions such as: -- `v5.2.0-alpha.1` for incomplete developer testing; -- `v5.2.0-beta.1` for broader testing of mostly complete behavior; -- `v5.2.0-rc.1` for an intended release candidate; -- `v5.2.0` for the stable release. +- `v5.2.1-alpha.1` for incomplete developer testing; +- `v5.2.1-beta.1` for broader testing of mostly complete behavior; +- `v5.2.1-rc.1` for an intended release candidate; +- `v5.2.1` for the stable release. Every pre-release should state what is incomplete, list known limitations and data risks, explain any backup requirements, and provide a feedback channel. diff --git a/docs/technical.md b/docs/technical.md index e898d33..ca96f7d 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -79,11 +79,16 @@ Home ranking / chronological Subscriptions The interface distinguishes network work from presentation: -- Check asks the scheduler to scan eligible channels. -- Reload reconstructs projections from local storage. +- **Reload videos** uses a manual scheduler batch over followed channels, bypasses normal successful-check intervals, preserves retry backoff and active leases, and reloads the visible inventory. It reports outcome and deferred counts. +- **Check for new videos** in Channels uses the same manual path restricted to the selected followed channel. It refreshes the card's state and exposes discoveries through Show. +- **Refresh** reconstructs projections from local storage. - Show reloads the complete subscription inventory without replacing its selected ordering. - Opening Home regenerates local ranking without initiating a Home-owned request. +Foreground scheduling excludes rare/dormant and leased channels before applying the batch limit, so they cannot starve eligible foreground work. Page scheduling uses a minimum delay and backs off when no work progresses. At startup, obsolete RSS 404 unavailability markers are repaired under channel leases; independent retry delays and live leases are preserved. + +Classification runs after successful RSS ingestion. It retains a bounded sample of 20 upload timestamps and distinguishes sustained frequent daily uploads from a short bulk-upload burst. Strong active/very-active evidence can promote a channel immediately. Downgrades require the class's quiet period and are limited to one step per day using `activityClassChangedAt`, rather than the timestamp of every classification run. The resulting class selects the next successful-check interval. Log reads current `rssAttempts` from storage when opened. + ## Views and pagination `feed-view-data.js` creates canonical projections. Home ranks the ready inventory @@ -96,6 +101,10 @@ Home and Subscriptions use stable 50-record keyset pages. Page traversal must ne Local search combines saved history and canonical feed records; there is no remote-search fallback. +Channels sorting is separate from Subscriptions video ordering. `feed-subscriptions-view.js` sorts followed-channel projections by name, `followedAt`, `latestUploadAt`, activity, or `lastAttemptAt`. The default is name ascending. The `ytvhtChannelsSort` local-storage preference retains the field and direction; activity ties use newest upload first. Missing upload dates and unknown activity sort last, while never-checked channels lead ascending last-check order. + +Feed and popup search debounce typing for 300 ms. Enter executes immediately, and clearing the field cancels pending search work. + ## Cards and incremental updates `feed-cards.js` owns shared feed-card rendering and local actions. `feed-history-view.js` separates stable row controls from video-specific metadata so progress/duration refreshes do not replace Remove or menu controls. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2aa02d4..f573548 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,31 +15,44 @@ If only one YouTube layout fails, include the page type and a screenshot in the ## Resume or metadata looks stale -Close and reopen the popup or select **Reload** in the full interface to render current local records. Duration appears only when it is available; playing an older saved video can fill it later. +Close and reopen the popup, reopen the affected view, or select **Refresh** in the feed to render current local records. Duration appears only when it is available; playing an older saved video can fill it later. If a History card refreshes, its **Remove** control and three-dot menu should remain present. Report a reproducible disappearance as a UI bug. ## The feed is empty 1. Open **Channels** and confirm at least one local follow exists. -2. Select **Check** and watch the scheduler status beside the button. -3. Allow the initialization scan to finish; existing cached cards should render before it completes. -4. Open **Subscriptions** or select **Show** to inspect the chronological inventory. +2. Open **Subscriptions**, select **Reload videos**, and watch the status beside it. For one channel, use **Channels → Check for new videos** on its card. +3. Allow the check to finish and review its checked, failed, and deferred counts. Existing cached cards should render before initialization completes. +4. Inspect the Subscriptions inventory. After background or per-channel discoveries, select **Show** to include them in the selected ordering. Public feeds can be missing, delayed, rate-limited, or temporarily unavailable. A failed channel does not mean your local history was lost. -## Check, Reload, or Show did something unexpected +## Reload videos, Refresh, or Show did something unexpected -- **Check** performs eligible public feed work. -- **Reload** only rebuilds the visible interface from local storage. -- **Show** changes to the chronological Subscriptions inventory. +- **Reload videos** checks followed channels and updates the list, including channels whose normal successful-check interval has not elapsed. +- **Refresh** only rebuilds the visible interface from local storage. +- **Show** opens the complete Subscriptions inventory in its selected upload-date or detection-date order. + +A deferred check means a channel is in failure backoff or already has a scan in progress. Repeated clicks do not bypass those limits. Open **Channels → Log** for that channel's recorded RSS attempts and failure details. Opening Home also regenerates its local ordering; it should not initiate a separate Home-owned request. +## A frequently uploading channel has a stale activity level or distant next check + +1. Confirm the installed version is 5.2.1 or later, then reopen the feed after updating. The scheduler repairs obsolete 30-day delays left by older RSS 404 handling. +2. Open **Channels** and find the channel by **Name**, or sort **Last checked** with oldest first to locate stale or never-checked channels. +3. Select **Check for new videos** on its card. A successful result refreshes the latest upload, activity, and next-check time. +4. Open **Log** to see the latest recorded attempts. Failed checks can leave the upload evidence stale; a deferred check has not made a new RSS attempt. + +An empty log means there are no recorded RSS reads yet. Repairing an old schedule does not create a synthetic log entry. If the problem persists after a successful check, report the channel URL, installed version, displayed activity and next-check time, and relevant log details. + ## Search cannot find a YouTube video Feed search is intentionally local. It can find only saved history and cached feed records. Use YouTube itself for remote discovery. +Search waits for a 300 ms pause in typing. Press Enter for an immediate search; clearing the field cancels pending work immediately. + ## A subscription import skipped channels Review the result in Settings. Previously removed channels are counted as ignored. Select the result action or open **Channels → Ignored**, then choose **Restore** or **Forget** for each record. diff --git a/package-lock.json b/package-lock.json index e90bb14..5eb822b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "youtube-local-history", - "version": "5.2.0", + "version": "5.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "youtube-local-history", - "version": "5.2.0", + "version": "5.2.1", "license": "MIT", "devDependencies": { "@babel/core": "^8.0.1", @@ -2181,38 +2181,41 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -6173,9 +6176,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index eb1669e..8909d03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "youtube-local-history", - "version": "5.2.0", + "version": "5.2.1", "description": "Private local YouTube history, progress tracking, channel subscriptions, analytics, and an in-browser feed.", "main": "src/content.js", "scripts": { diff --git a/scripts/docs-screenshot-catalog.json b/scripts/docs-screenshot-catalog.json index 0fb615f..5d344cd 100644 --- a/scripts/docs-screenshot-catalog.json +++ b/scripts/docs-screenshot-catalog.json @@ -48,7 +48,7 @@ "readySelector": "#grid .ytvht-feed-card", "display": { "width": 1440, "height": 960 }, "alt": "The re:Watch Subscriptions tab showing recent cached uploads from locally followed channels.", - "caption": "Subscriptions lists cached uploads from locally followed channels in chronological order." + "caption": "Subscriptions lists cached uploads in the selected upload-date or detection-date order; Reload videos checks for new uploads." }, { "id": "feed-shorts", @@ -117,8 +117,8 @@ "capture": { "type": "viewport" }, "readySelector": "#subscriptionsList .subs-card", "display": { "width": 1440, "height": 960 }, - "alt": "The re:Watch Channels tab showing channels followed locally by the extension.", - "caption": "Channels manages the explicit channel list followed by re:Watch." + "alt": "The re:Watch Channels tab with sorting, per-channel checks, activity, next-check times, and RSS logs.", + "caption": "Sort local follows and check each channel's uploads, activity, and scan history." }, { "id": "feed-analytics", diff --git a/scripts/generate-docs-screenshots.js b/scripts/generate-docs-screenshots.js index 9e195c4..d84a386 100644 --- a/scripts/generate-docs-screenshots.js +++ b/scripts/generate-docs-screenshots.js @@ -241,7 +241,8 @@ async function seedExtension(context, extensionOrigin, fixture, fixedNow) { initializationState: 'complete', lastAttemptAt: data.now, lastSuccessfulCheckAt: data.now, - nextEligibleCheckAt: data.now + 365 * 24 * 60 * 60 * 1000, + nextEligibleCheckAt: data.now + 3 * 60 * 60 * 1000, + latestUploadAt: channel.latestUploadAt, scanLeaseUntil: null, scanRunId: null, activityClass: 'active', diff --git a/src/_locales/bg/feed-dynamic.json b/src/_locales/bg/feed-dynamic.json index a8409b0..9bae82b 100644 --- a/src/_locales/bg/feed-dynamic.json +++ b/src/_locales/bg/feed-dynamic.json @@ -1,4 +1,21 @@ { + "feed_channels_sort": { "message": "Подреждане на каналите" }, + "feed_channels_sort_name": { "message": "Име" }, + "feed_channels_sort_followed": { "message": "Дата на последване" }, + "feed_channels_sort_uploaded": { "message": "Последно качване" }, + "feed_channels_sort_activity": { "message": "Активност" }, + "feed_channels_sort_checked": { "message": "Последна проверка" }, + "feed_channels_sort_az": { "message": "А–Я" }, + "feed_channels_sort_za": { "message": "Я–А" }, + "feed_channels_sort_oldest": { "message": "Най-старите първо" }, + "feed_channels_sort_newest": { "message": "Най-новите първо" }, + "feed_channels_sort_least_active": { "message": "Най-слабо активните първо" }, + "feed_channels_sort_most_active": { "message": "Най-активните първо" }, + "feed_channels_sort_reverse": { "message": "Обръщане на посоката на подреждане" }, + "feed_channel_check": { "message": "Проверка за нови видеа" }, + "feed_rss_log_failed": { "message": "Дневникът на RSS проверките не можа да се зареди. Опитайте отново." }, + "feed_check_incomplete": { "message": "Някои канали не можаха да бъдат проверени. Натиснете „Презареждане на видеата“ за подробности." }, + "feed_reload_summary": { "message": "Проверени канали: $1 · неуспешни: $2 · отложени: $3." }, "feed_all_data_reset": { "message": "Всички локални данни бяха нулирани." }, "feed_all_subscriptions_removed": { "message": "Всички локални абонаменти бяха премахнати." }, "feed_already_following": { "message": "Вече следвате този канал с re:Watch." }, diff --git a/src/_locales/de/feed-dynamic.json b/src/_locales/de/feed-dynamic.json index 9b54cd4..d7d8dca 100644 --- a/src/_locales/de/feed-dynamic.json +++ b/src/_locales/de/feed-dynamic.json @@ -1,4 +1,21 @@ { + "feed_channels_sort": { "message": "Kanäle sortieren" }, + "feed_channels_sort_name": { "message": "Name" }, + "feed_channels_sort_followed": { "message": "Gefolgt seit" }, + "feed_channels_sort_uploaded": { "message": "Letzter Upload" }, + "feed_channels_sort_activity": { "message": "Aktivität" }, + "feed_channels_sort_checked": { "message": "Zuletzt geprüft" }, + "feed_channels_sort_az": { "message": "A–Z" }, + "feed_channels_sort_za": { "message": "Z–A" }, + "feed_channels_sort_oldest": { "message": "Älteste zuerst" }, + "feed_channels_sort_newest": { "message": "Neueste zuerst" }, + "feed_channels_sort_least_active": { "message": "Wenig aktive zuerst" }, + "feed_channels_sort_most_active": { "message": "Sehr aktive zuerst" }, + "feed_channels_sort_reverse": { "message": "Sortierrichtung umkehren" }, + "feed_channel_check": { "message": "Nach neuen Videos suchen" }, + "feed_rss_log_failed": { "message": "Das RSS-Protokoll konnte nicht geladen werden. Bitte versuche es erneut." }, + "feed_check_incomplete": { "message": "Einige Kanäle konnten nicht geprüft werden. Lade die Videos erneut, um Details zu sehen." }, + "feed_reload_summary": { "message": "$1 Kanäle geprüft · $2 fehlgeschlagen · $3 zurückgestellt." }, "feed_all_data_reset": { "message": "Alle lokalen Daten wurden zurückgesetzt." }, "feed_all_subscriptions_removed": { "message": "Alle lokalen Abonnements wurden entfernt." }, "feed_already_following": { "message": "Du folgst diesem Kanal bereits mit re:Watch." }, diff --git a/src/_locales/en/feed-dynamic.json b/src/_locales/en/feed-dynamic.json index b32a5b9..32b11de 100644 --- a/src/_locales/en/feed-dynamic.json +++ b/src/_locales/en/feed-dynamic.json @@ -1,4 +1,21 @@ { + "feed_channels_sort": { "message": "Sort channels" }, + "feed_channels_sort_name": { "message": "Name" }, + "feed_channels_sort_followed": { "message": "Date followed" }, + "feed_channels_sort_uploaded": { "message": "Latest upload" }, + "feed_channels_sort_activity": { "message": "Activity" }, + "feed_channels_sort_checked": { "message": "Last checked" }, + "feed_channels_sort_az": { "message": "A–Z" }, + "feed_channels_sort_za": { "message": "Z–A" }, + "feed_channels_sort_oldest": { "message": "Oldest first" }, + "feed_channels_sort_newest": { "message": "Newest first" }, + "feed_channels_sort_least_active": { "message": "Least active first" }, + "feed_channels_sort_most_active": { "message": "Most active first" }, + "feed_channels_sort_reverse": { "message": "Reverse sort direction" }, + "feed_channel_check": { "message": "Check for new videos" }, + "feed_rss_log_failed": { "message": "Could not load the RSS read log. Please try again." }, + "feed_check_incomplete": { "message": "Some channels could not be checked. Try Reload videos for details." }, + "feed_reload_summary": { "message": "Checked $1 channels · $2 failed · $3 deferred." }, "feed_all_data_reset": { "message": "All local data has been reset.", "description": "Status after resetting all local data" }, "feed_all_subscriptions_removed": { "message": "All local subscriptions removed.", "description": "Status after removing all local subscriptions" }, "feed_already_following": { "message": "Already following this channel with re:Watch.", "description": "Status when a local channel is already followed" }, diff --git a/src/_locales/es/feed-dynamic.json b/src/_locales/es/feed-dynamic.json index e3ec1f7..645e82f 100644 --- a/src/_locales/es/feed-dynamic.json +++ b/src/_locales/es/feed-dynamic.json @@ -1,4 +1,21 @@ { + "feed_channels_sort": { "message": "Ordenar canales" }, + "feed_channels_sort_name": { "message": "Nombre" }, + "feed_channels_sort_followed": { "message": "Fecha de seguimiento" }, + "feed_channels_sort_uploaded": { "message": "Última subida" }, + "feed_channels_sort_activity": { "message": "Actividad" }, + "feed_channels_sort_checked": { "message": "Última comprobación" }, + "feed_channels_sort_az": { "message": "A–Z" }, + "feed_channels_sort_za": { "message": "Z–A" }, + "feed_channels_sort_oldest": { "message": "Más antiguos primero" }, + "feed_channels_sort_newest": { "message": "Más recientes primero" }, + "feed_channels_sort_least_active": { "message": "Menos activos primero" }, + "feed_channels_sort_most_active": { "message": "Más activos primero" }, + "feed_channels_sort_reverse": { "message": "Invertir el orden" }, + "feed_channel_check": { "message": "Buscar vídeos nuevos" }, + "feed_rss_log_failed": { "message": "No se pudo cargar el registro de lecturas RSS. Inténtalo de nuevo." }, + "feed_check_incomplete": { "message": "No se pudieron comprobar algunos canales. Vuelve a cargar los vídeos para ver los detalles." }, + "feed_reload_summary": { "message": "$1 canales comprobados · $2 fallidos · $3 aplazados." }, "feed_all_data_reset": { "message": "Se han restablecido todos los datos locales." }, "feed_all_subscriptions_removed": { "message": "Se han eliminado todas las suscripciones locales." }, "feed_already_following": { "message": "Ya sigues este canal con re:Watch." }, diff --git a/src/_locales/fr/feed-dynamic.json b/src/_locales/fr/feed-dynamic.json index 399e24b..6848392 100644 --- a/src/_locales/fr/feed-dynamic.json +++ b/src/_locales/fr/feed-dynamic.json @@ -1,4 +1,21 @@ { + "feed_channels_sort": { "message": "Trier les chaînes" }, + "feed_channels_sort_name": { "message": "Nom" }, + "feed_channels_sort_followed": { "message": "Date de suivi" }, + "feed_channels_sort_uploaded": { "message": "Dernière mise en ligne" }, + "feed_channels_sort_activity": { "message": "Activité" }, + "feed_channels_sort_checked": { "message": "Dernière vérification" }, + "feed_channels_sort_az": { "message": "A–Z" }, + "feed_channels_sort_za": { "message": "Z–A" }, + "feed_channels_sort_oldest": { "message": "Les plus anciens d’abord" }, + "feed_channels_sort_newest": { "message": "Les plus récents d’abord" }, + "feed_channels_sort_least_active": { "message": "Les moins actives d’abord" }, + "feed_channels_sort_most_active": { "message": "Les plus actives d’abord" }, + "feed_channels_sort_reverse": { "message": "Inverser le sens du tri" }, + "feed_channel_check": { "message": "Rechercher de nouvelles vidéos" }, + "feed_rss_log_failed": { "message": "Impossible de charger le journal des lectures RSS. Veuillez réessayer." }, + "feed_check_incomplete": { "message": "Certaines chaînes n’ont pas pu être vérifiées. Rechargez les vidéos pour voir les détails." }, + "feed_reload_summary": { "message": "$1 chaînes vérifiées · $2 échecs · $3 reports." }, "feed_all_data_reset": { "message": "Toutes les données locales ont été réinitialisées." }, "feed_all_subscriptions_removed": { "message": "Tous les abonnements locaux ont été supprimés." }, "feed_already_following": { "message": "Vous suivez déjà cette chaîne avec re:Watch." }, diff --git a/src/feed-channel-classification.js b/src/feed-channel-classification.js index a4239b3..b1c4713 100644 --- a/src/feed-channel-classification.js +++ b/src/feed-channel-classification.js @@ -1,13 +1,14 @@ (function (root) { 'use strict'; - const CLASSIFICATION_VERSION = 1; + const CLASSIFICATION_VERSION = 2; const ACTIVITY_CLASSES = Object.freeze([ 'unknown', 'very_active', 'active', 'regular', 'occasional', 'rare', 'dormant', 'reactivated' ]); const RING_LIMIT = 20; const DAY_MS = 24 * 60 * 60 * 1000; const SESSION_GAP_MS = 12 * 60 * 60 * 1000; + const QUIET_DAYS_BEFORE_DOWNGRADE = Object.freeze({ very_active: 2, active: 10, regular: 30, occasional: 90 }); function median(values) { if (!values.length) return null; @@ -57,6 +58,12 @@ ).filter((value) => value > 0); const medianIntervalMs = median(intervals); const ageMs = Math.max(0, now - latest); + // The bounded RSS sample can contain an entire day's busy schedule + // inside one 12-hour session. Count uploads spread through the day, + // without mistaking a bulk upload within a few minutes for that rate. + const recentDay = timestamps.filter((timestamp) => timestamp >= now - DAY_MS && timestamp <= now); + const daySpanMs = recentDay.length ? recentDay[0] - recentDay[recentDay.length - 1] : 0; + if (recentDay.length >= 6 && daySpanMs >= 3 * 60 * 60 * 1000 && ageMs <= 12 * 60 * 60 * 1000) return 'very_active'; if (sessions7d >= 10 || (medianIntervalMs && medianIntervalMs <= 12 * 60 * 60 * 1000 && ageMs <= 2 * DAY_MS)) return 'very_active'; if (sessions7d >= 4 || (medianIntervalMs && medianIntervalMs <= 3 * DAY_MS && ageMs <= 10 * DAY_MS)) return 'active'; if (sessions30d >= 2 || (medianIntervalMs && medianIntervalMs <= 14 * DAY_MS && ageMs <= 30 * DAY_MS)) return 'regular'; @@ -91,9 +98,16 @@ const hasNewUpload = !!latestUploadAt && latestUploadAt > latestKnown; const observed = observedActivityClass(timestamps, now); const current = existing && existing.activityClass; - const activityClass = hasNewUpload && (current === 'dormant' || current === 'rare') - ? 'reactivated' - : stepTowardActivityClass(current, observed); + const strongActivity = observed === 'very_active' || observed === 'active'; + let activityClass = hasNewUpload && (current === 'dormant' || current === 'rare') && !strongActivity + ? 'reactivated' : stepTowardActivityClass(current, observed); + if (strongActivity && classRank(observed) < classRank(current)) activityClass = observed; + const downgrading = classRank(current) !== null && classRank(observed) > classRank(current); + const quietDays = QUIET_DAYS_BEFORE_DOWNGRADE[current]; + if (downgrading && ((quietDays && latestUploadAt && now - latestUploadAt < quietDays * DAY_MS) || + (existing?.activityClassChangedAt && now - existing.activityClassChangedAt < DAY_MS))) { + activityClass = current; + } return { classificationVersion: CLASSIFICATION_VERSION, recentUploadTimestamps: timestamps, @@ -110,6 +124,8 @@ activityClass, classificationConfidence: Math.min(1, timestamps.length / 10), classificationUpdatedAt: now, + activityClassChangedAt: activityClass === current && existing?.activityClassChangedAt + ? existing.activityClassChangedAt : now, reactivatedAt: activityClass === 'reactivated' ? now : null, hasNewUpload }; diff --git a/src/feed-refresh.js b/src/feed-refresh.js index 1100959..431516e 100644 --- a/src/feed-refresh.js +++ b/src/feed-refresh.js @@ -397,13 +397,27 @@ async function checkForNewVideos() { setRefreshUi(true); setStatus('', false); try { - await requestPageActiveFeedWork(); + const { result } = await requestPageActiveFeedWork({ manual: true }); + await loadData({ requireCanonicalInventory: true }); + newlyShownFeedVideoIds = []; + await replacePendingFeedDiscovery({ videoIds: [], discoveredAt: 0 }); + pendingFeedNoticeState = { busy: false, error: '' }; + refreshActiveFeedDataView(); + const failed = Number(result.outcomes?.failed || 0) + Number(result.outcomes?.timed_out || 0); + const checked = Object.values(result.outcomes || {}).reduce((sum, count) => sum + count, 0); + const message = tFeed('feed_reload_summary', 'Checked $1 channels · $2 failed · $3 deferred.', [ + feedFormatNumber(checked), feedFormatNumber(failed), feedFormatNumber(result.skippedCount || 0) + ]); + setPageActiveSyncStatus(message, false); + setStatus(message, false); } catch (e) { console.warn('[feed] upload check failed', e && e.message); - setStatus(tFeed( + const message = tFeed( 'feed_refresh_failed_status', 'Could not check for new videos. Please try again.' - ), false); + ); + setPageActiveSyncStatus(message, false); + setStatus(message, false); } finally { setRefreshUi(false); } diff --git a/src/feed-scheduler.js b/src/feed-scheduler.js index 3681eb0..ac96fa5 100644 --- a/src/feed-scheduler.js +++ b/src/feed-scheduler.js @@ -136,10 +136,33 @@ async start() { const recoveredLeaseCount = await this.recoverExpiredLeases(); + await this.repairLegacyUnavailableSchedules(); const initializedChannelCount = await this.initializeSubscriptions(); return { recoveredLeaseCount, initializedChannelCount }; } + async repairLegacyUnavailableSchedules() { + const at = nowFrom(this.clock); + const states = await this.storage.listChannelSyncStates(); + const legacy = states.filter((state) => Number(state.unavailableStatus) === 404 && + Number(state.scanLeaseUntil || 0) <= at); + let repaired = 0; + for (const state of legacy) { + // Before v5.1, a 404 imposed a 30-day wait. Queue a real check + // under today's policy; preserve any independent retry delay. + const runId = `legacy-repair-${at}-${++this.runSequence}`; + const claim = await this.storage.claimChannelSyncState(state.channelId, { runId, now: at, leaseMs: this.leaseMs }); + if (!claim.claimed) continue; + const stillLegacy = Number(claim.state.unavailableStatus) === 404; + const released = await this.storage.releaseChannelSyncState(state.channelId, runId, stillLegacy ? { + unavailableStatus: null, unavailableAt: null, + nextEligibleCheckAt: Math.max(at, Number(claim.state.retryAfter || 0)) + } : {}); + if (stillLegacy && released) repaired += 1; + } + return repaired; + } + async getInitializationProgress() { const [subscriptions, states] = await Promise.all([ this.storage.listSubscriptionRecords(), @@ -156,7 +179,8 @@ async getNextEligibleCheckAt() { const eligible = (await this.storage.listChannelSyncStates()) - .map((state) => Number(state && state.nextEligibleCheckAt || 0)) + .map((state) => Math.max(Number(state && state.nextEligibleCheckAt || 0), + Number(state && state.scanLeaseUntil || 0), Number(state && state.retryAfter || 0))) .filter((at) => Number.isFinite(at) && at > 0); return eligible.length ? Math.min(...eligible) : Infinity; } @@ -200,8 +224,10 @@ async runForeground(options = {}) { const at = nowFrom(this.clock); - const states = await this.storage.getEligibleChannelSyncStates(at, Number(options.limit || this.foregroundBatchSize)); - const selected = states.filter((state) => !this.isDormantState(state)); + const states = await this.storage.getEligibleChannelSyncStates(at); + // Low-activity channels must not fill the batch before filtering. + const selected = states.filter((state) => !this.isDormantState(state) && Number(state.scanLeaseUntil || 0) <= at) + .slice(0, Number(options.limit || this.foregroundBatchSize)); const retryOnly = selected.length > 0 && selected.every((state) => Boolean(state.lastRssError)); return this.runBatch(selected, { ...options, @@ -210,6 +236,26 @@ }); } + async runManual(options = {}) { + await this.initializeSubscriptions(options.channelIds); + const at = nowFrom(this.clock); + const [subscriptions, states] = await Promise.all([ + this.storage.listSubscriptionRecords(), this.storage.listChannelSyncStates() + ]); + const requestedIds = options.channelIds ? new Set(uniqueChannelIds(options.channelIds)) : null; + const followedIds = new Set(subscriptions.filter((subscription) => + contracts.canInitializeSubscription(subscription) && (!requestedIds || requestedIds.has(subscription.channelId))) + .map((subscription) => subscription.channelId)); + // A manual reload bypasses successful-check intervals, including + // rare/dormant channels, but preserves failure backoff and leases. + const selected = states.filter((state) => followedIds.has(state.channelId) && + Number(state.retryAfter || 0) <= at && Number(state.scanLeaseUntil || 0) <= at); + const result = await this.runBatch(selected, { + kind: 'manual', limit: selected.length || 1, concurrency: this.concurrency + }); + return { ...result, skippedCount: result.skippedCount + followedIds.size - selected.length }; + } + async runBatch(states, options = {}) { const selected = (states || []).slice(0, Number(options.limit || this.foregroundBatchSize)); const runId = String(options.runId || `${options.kind || 'foreground'}-${nowFrom(this.clock)}-${++this.runSequence}`); @@ -248,7 +294,8 @@ const workerCount = Math.min(Math.max(1, Number(options.concurrency || this.concurrency)), selected.length); await Promise.all(Array.from({ length: workerCount }, worker)); this.cancelledRunIds.delete(runId); - const result = { runId, completed, total, insertedVideoCount, insertedVideoIds, active: false }; + const skippedCount = total - Object.values(outcomes).reduce((sum, count) => sum + count, 0); + const result = { runId, completed, total, insertedVideoCount, insertedVideoIds, active: false, outcomes, skippedCount }; await this.recordRunDiagnostic({ ...result, kind: options.kind || 'foreground', startedAt, completedAt: nowFrom(this.clock), outcomes }); await this.cleanupRetainedFeedData(); return result; diff --git a/src/feed-subscriptions-view.js b/src/feed-subscriptions-view.js index 06e3dc0..7df6e18 100644 --- a/src/feed-subscriptions-view.js +++ b/src/feed-subscriptions-view.js @@ -147,16 +147,55 @@ function setSubscriptionAddStatus(message, isError) { status.style.color = isError ? 'var(--danger-text)' : ''; } -function showSubscriptionRssLog(subscription) { +const subscriptionCheckStates = new Map(); + +async function checkSubscriptionForNewVideos(subscription, button) { + const channelId = subscription.channelId; + if (subscriptionCheckStates.get(channelId)?.busy) return; + const state = { busy: true, message: '' }; + subscriptionCheckStates.set(channelId, state); + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + button.textContent = tFeed('feed_refreshing', 'Checking…'); + try { + const scheduler = ensureSharedFeedScheduler(); + if (!scheduler) throw new Error('Feed scheduler is unavailable'); + const result = await scheduler.runManual({ channelIds: [channelId] }); + const checked = Object.values(result.outcomes || {}).reduce((sum, count) => sum + count, 0); + const failed = Number(result.outcomes?.failed || 0) + Number(result.outcomes?.timed_out || 0); + state.message = tFeed('feed_reload_summary', 'Checked $1 channels · $2 failed · $3 deferred.', [ + feedFormatNumber(checked), feedFormatNumber(failed), feedFormatNumber(result.skippedCount || 0) + ]); + if (result.insertedVideoIds?.length) await showRetainedNewFeedVideos(result.insertedVideoIds); + } catch (error) { + console.warn('[subscriptions] channel check failed', error && error.message); + state.message = tFeed('feed_refresh_failed_status', 'Could not check for new videos. Please try again.'); + } finally { + state.busy = false; + button.disabled = false; + button.setAttribute('aria-busy', 'false'); + button.textContent = tFeed('feed_channel_check', 'Check for new videos'); + if (subscriptionsActive) await renderSubscriptions(); + } +} + +async function showSubscriptionRssLog(subscription) { + let attempts = []; + let readFailed = false; + try { + const current = await ytIndexedDBStorage.getChannelSyncState(subscription.channelId); + attempts = Array.isArray(current?.rssAttempts) ? current.rssAttempts.slice().reverse() : []; + } catch (_) { readFailed = true; } const dialog = document.createElement('dialog'); dialog.className = 'rss-log-dialog'; const title = document.createElement('h2'); title.textContent = tFeed('feed_rss_log_title', 'RSS read log · $1', [subscription.channelName || subscription.channelId]); dialog.appendChild(title); - const attempts = Array.isArray(subscription.rssAttempts) ? subscription.rssAttempts.slice().reverse() : []; if (!attempts.length) { const empty = document.createElement('p'); - empty.textContent = tFeed('feed_rss_log_empty', 'No RSS reads recorded yet.'); + empty.textContent = readFailed + ? tFeed('feed_rss_log_failed', 'Could not load the RSS read log. Please try again.') + : tFeed('feed_rss_log_empty', 'No RSS reads recorded yet.'); dialog.appendChild(empty); } else { const list = document.createElement('ol'); @@ -223,6 +262,74 @@ function setupSubscriptionAddForm() { }); } +const CHANNELS_SORT_KEY = 'ytvhtChannelsSort'; +const CHANNELS_SORT_DEFAULTS = Object.freeze({ name: 'asc', followedAt: 'desc', latestUploadAt: 'desc', activity: 'desc', lastAttemptAt: 'asc' }); +let channelsSortPreference = readChannelsSortPreference(); + +function readChannelsSortPreference() { + try { + const saved = JSON.parse(localStorage.getItem(CHANNELS_SORT_KEY)); + if (Object.prototype.hasOwnProperty.call(CHANNELS_SORT_DEFAULTS, saved?.field) && ['asc', 'desc'].includes(saved.direction)) return saved; + } catch (_) { /* Use the default when preferences cannot be read. */ } + return { field: 'name', direction: 'asc' }; +} + +function sortChannelSubscriptions(subscriptions, preference = channelsSortPreference) { + const { field, direction } = preference; + const multiplier = direction === 'asc' ? 1 : -1; + const activity = { very_active: 6, active: 5, regular: 4, occasional: 3, reactivated: 2, rare: 1, dormant: 0 }; + const names = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); + const compareNames = (a, b) => names.compare( + decodeHtmlEntities(a.channelName || a.channelTitle || a.channelId), + decodeHtmlEntities(b.channelName || b.channelTitle || b.channelId) + ); + return [...subscriptions].sort((a, b) => { + if (field === 'name') return multiplier * compareNames(a, b) || a.channelId.localeCompare(b.channelId); + const left = field === 'activity' ? activity[a.activityClass] : Number(a[field] || 0); + const right = field === 'activity' ? activity[b.activityClass] : Number(b[field] || 0); + // Missing dates/activity stay last, except never-checked channels: + // those are the oldest checks and belong first in ascending order. + const missing = (value) => !Number.isFinite(value) || (field !== 'activity' && field !== 'lastAttemptAt' && value <= 0); + if (missing(left) !== missing(right)) return missing(left) ? 1 : -1; + const primary = missing(left) ? 0 : multiplier * (left - right); + const latestUpload = field === 'activity' ? Number(b.latestUploadAt || 0) - Number(a.latestUploadAt || 0) : 0; + return primary || latestUpload || compareNames(a, b) || a.channelId.localeCompare(b.channelId); + }); +} + +function setupChannelsSort() { + const select = document.getElementById('channelsSort'); + const direction = document.getElementById('channelsSortDirection'); + if (!select || !direction) return; + select.value = channelsSortPreference.field; + const ascending = channelsSortPreference.direction === 'asc'; + const labels = channelsSortPreference.field === 'name' + ? ['feed_channels_sort_az', 'A–Z', 'feed_channels_sort_za', 'Z–A'] + : channelsSortPreference.field === 'activity' + ? ['feed_channels_sort_least_active', 'Least active first', 'feed_channels_sort_most_active', 'Most active first'] + : ['feed_channels_sort_oldest', 'Oldest first', 'feed_channels_sort_newest', 'Newest first']; + const offset = ascending ? 0 : 2; + direction.textContent = `${ascending ? '↑' : '↓'} ${tFeed(labels[offset], labels[offset + 1])}`; + direction.title = tFeed('feed_channels_sort_reverse', 'Reverse sort direction'); + if (select.dataset.bound === 'true') return; + select.dataset.bound = 'true'; + const apply = () => { + try { localStorage.setItem(CHANNELS_SORT_KEY, JSON.stringify(channelsSortPreference)); } catch (_) { /* Keep the selection for this page. */ } + setupChannelsSort(); + channelMetadataObserver?.disconnect(); + channelMetadataObserver = null; + renderSubscriptions(); + }; + select.addEventListener('change', () => { + channelsSortPreference = { field: select.value, direction: CHANNELS_SORT_DEFAULTS[select.value] }; + apply(); + }); + direction.addEventListener('click', () => { + channelsSortPreference.direction = channelsSortPreference.direction === 'asc' ? 'desc' : 'asc'; + apply(); + }); +} + async function renderSubscriptions() { const list = document.getElementById('subscriptionsList'); const empty = document.getElementById('subscriptionsEmpty'); @@ -233,10 +340,12 @@ async function renderSubscriptions() { const ignoredTab = document.getElementById('channelsIgnoredTab'); const addForm = document.getElementById('subscriptionAddForm'); const addStatus = document.getElementById('subscriptionAddStatus'); + const sortControls = document.getElementById('channelsSortControls'); if (!list || !empty || !count) return; setupSubscriptionAddForm(); setupSubscriptionTabs(); + setupChannelsSort(); let subscriptions = []; let ignoredChannels = []; let loadError = null; @@ -257,6 +366,7 @@ async function renderSubscriptions() { loadError = error; } if (loadError) { + if (sortControls) sortControls.hidden = true; if (tabs) tabs.hidden = true; if (addForm) addForm.style.display = 'none'; if (addStatus) addStatus.style.display = 'none'; @@ -268,6 +378,8 @@ async function renderSubscriptions() { } if (!ignoredChannels.length) channelsInternalTab = 'following'; const showingIgnored = channelsInternalTab === 'ignored' && ignoredChannels.length > 0; + if (sortControls) sortControls.hidden = showingIgnored || subscriptions.length === 0; + subscriptions = sortChannelSubscriptions(subscriptions); if (tabs) tabs.hidden = ignoredChannels.length === 0; if (ignoredTab) { ignoredTab.hidden = ignoredChannels.length === 0; @@ -350,10 +462,24 @@ async function renderSubscriptions() { const actions = document.createElement('div'); actions.className = 'subs-actions'; + const checkState = subscriptionCheckStates.get(sub.channelId); + const check = document.createElement('button'); + check.className = 'btn'; + check.dataset.action = 'check'; + check.disabled = checkState?.busy === true; + check.setAttribute('aria-busy', check.disabled ? 'true' : 'false'); + check.textContent = check.disabled ? tFeed('feed_refreshing', 'Checking…') : tFeed('feed_channel_check', 'Check for new videos'); + check.addEventListener('click', () => checkSubscriptionForNewVideos(sub, check)); + actions.appendChild(check); const log = document.createElement('button'); log.className = 'btn'; + log.dataset.action = 'log'; log.textContent = tFeed('feed_rss_log', 'Log'); - log.addEventListener('click', () => showSubscriptionRssLog(sub)); + log.addEventListener('click', async () => { + log.disabled = true; + try { await showSubscriptionRssLog(sub); } + finally { log.disabled = false; } + }); actions.appendChild(log); const unsubscribe = document.createElement('button'); unsubscribe.className = 'btn'; @@ -376,6 +502,13 @@ async function renderSubscriptions() { }); actions.appendChild(unsubscribe); row.appendChild(actions); + if (checkState?.message) { + const checkMessage = document.createElement('div'); + checkMessage.className = 'subs-meta'; + checkMessage.setAttribute('role', 'status'); + checkMessage.textContent = checkState.message; + row.appendChild(checkMessage); + } list.appendChild(row); }); if (showingIgnored) { diff --git a/src/feed.html b/src/feed.html index 4a30cbb..7ea99e4 100644 --- a/src/feed.html +++ b/src/feed.html @@ -280,6 +280,9 @@ .subs-add label { display: block; font-size: 13px; font-weight: 600; white-space: nowrap; } .subs-add input { flex: 1; min-width: 180px; padding: 9px 10px; color: var(--text); background: var(--chip); border: 1px solid var(--border); border-radius: 6px; } .subs-add-status { min-height: 20px; margin: 0 0 12px; color: var(--text-2); font-size: 13px; } + .channels-sort-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; } + .channels-sort-controls[hidden] { display: none; } + .channels-sort-controls label { color: var(--text-2); font-size: 14px; } .subs-list { display: flex; flex-direction: column; width: 100%; @@ -1800,6 +1803,17 @@

+
+ + + +