Compare commits

...

258 Commits

Author SHA1 Message Date
Anarios 3657de9b2c Fix Shorts support and add userscript voting
Harden Shorts controls, reaction state, and navigation; share voting logic
with the extension; improve ratio-bar rendering; and add comprehensive Jest,
Playwright, artifact, and live-browser regression coverage.
2026-09-01 23:41:28 +02:00
Oliver L 4e6dfd1977 Add Traditional Chinese README and normalize language navigation (#1299)
Add the zh-TW README, improve Simplified Chinese wording, synchronize the API example, and restore localized navigation labels.
2026-08-08 17:23:14 +02:00
Anarios 51afbdaee1 Fix 4.0.4 lockfile version 2026-05-02 09:13:04 +02:00
Anarios 6fb6726a0c Prepare next extension version 2026-05-02 09:09:35 +02:00
Anarios 0afa1be8be Remove localhost extension permissions 2026-05-02 09:06:16 +02:00
Anarios d6cbaadf05 Prepare 4.0.0.3 extension release 2026-05-01 06:26:51 +02:00
Anarios 54ff493ea4 Prepare AMO source submission 2026-05-01 06:08:29 +02:00
Anarios ea7f1c0b38 Translation improvements 2025-12-11 22:49:12 +01:00
Anarios c0c3a2ea5f #1156 and #1149 - firx for shorts 2025-10-29 16:17:22 +01:00
Anarios 3d3c7ef60c version bump 2025-10-24 15:35:41 +02:00
Dmitrii Selivanov 590a764bd6 Defer showing changelog untill browser restart. Make changelog easier to identify with the extension (better title, logo) (#1233)
* Deferr showing changelog untill browser restart.

Make changelog easier to identify with the extension (better title, logo)

* Deferr showing changelog untill browser restart.

Make changelog easier to identify with the extension (better title, logo)
2025-10-24 15:31:55 +02:00
Anarios 00242eaef0 #1227 - Premium popup feature annoying fix. 2025-10-22 10:35:16 +02:00
Dmitrii Selivanov 40fda6dd32 Advanced stats for patreon supporters (#1222)
* auth works

* Works after review.

* switched to auth flow

* all works well in chrome

* all works well in chrome and firefox

* translations harmonized

* translations harmonized

* website translations

* Translations harmonized.

* Everything kinda works

* Almost works

* all works

* all works

* all works #2

* Streamline premium analytics range handling

* Restore premium analytics selection flow

* Fix analytics zoom interactions

* Add per-section expansion controls to premium analytics

* Adjust map legend visibility for premium analytics

* Add missing country synonyms for premium map

* Remove legacy premium badge overlay

* Polish premium analytics controls for light theme

* Refresh map legend when toggling sections

* Improve light-mode styling for range selectors

* Fix map mode switching regression

* Fix for panel expand

* Typo fix

* webpack fix

* before next steps.

* split, zero-buckets

* bucket size indicated on UI

* all works

* first/last toggle

* breadcrumbs

* release
2025-10-20 18:43:06 +02:00
Anarios fb46b07db6 Shorts fix, logging fix 2025-09-06 15:21:37 +02:00
Anarios 70d4fda075 shorts fix 2025-08-25 09:12:25 +02:00
Anarios bb527d6da1 Readmes update, vue reverted to v2. 2025-07-13 21:39:44 +02:00
Kryštof Černý 5ec6f3730c Update Czech language data (#983)
* Update cs/messages.json

* Add Czech store description
2025-07-13 19:44:47 +02:00
Dmitrii Selivanov 5c6f698c3a Merge pull request #997
Added Vietnamese translation and fixed typo.
2025-07-13 19:43:37 +02:00
Anarios 180840a063 Merge branch 'main' into fork/khangquangtran/main
# Conflicts:
#	CONTRIBUTING.md
#	CONTRIBUTINGfr.md
#	CONTRIBUTINGnl.md
#	CONTRIBUTINGpl.md
#	CONTRIBUTINGru.md
#	CONTRIBUTINGtr.md
#	CONTRIBUTINGuk.md
#	Docs/FAQ.md
#	Docs/FAQfr.md
#	Docs/FAQnl.md
#	Docs/FAQpl.md
#	Docs/FAQru.md
#	Docs/FAQtr.md
#	Docs/FAQuk.md
#	Docs/SECURITY-FAQ.md
#	Docs/SECURITY-FAQfr.md
#	Docs/SECURITY-FAQnl.md
#	Docs/SECURITY-FAQpl.md
#	Docs/SECURITY-FAQru.md
#	Docs/SECURITY-FAQtr.md
#	Docs/SECURITY-FAQuk.md
#	Docs/readme.md
#	Docs/readmefr.md
#	Docs/readmenl.md
#	Docs/readmetr.md
#	README.md
#	READMEcn.md
#	READMEde.md
#	READMEes.md
#	READMEfr.md
#	READMEgr.md
#	READMEja.md
#	READMEnl.md
#	READMEpl.md
#	READMEru.md
#	READMEsv.md
#	READMEtr.md
#	READMEuk.md
#	Website/README.md
#	Website/READMEnl.md
#	Website/READMEtr.md
#	Website/layouts/default.vue
#	Website/nuxt.config.js
#	Website/pages/links.vue
#	Website/store/README.md
#	Website/store/READMEnl.md
#	Website/store/READMEtr.md
2025-07-13 19:43:14 +02:00
Ntrie1 3f1b07310e add Bulgarian translation (#1015)
Co-authored-by: Anarios <selivano.d@gmail.com>
2025-07-13 19:22:46 +02:00
Theofanis Sarmidis bd4b8dcee4 Greek Language support (#1026)
* Create READMEgr.md

* Create CONTRIBUTINGgr.md

* Create extension-description-store-greek.txt

* Rename Extensions/combined/_locales/gr/messages.json to Extensions/combined/_locales/el/messages.json

* Update messages.json

correct locale for the GREEK language is 'el'

* Update READMEgr.md

* Update READMEgr.md

* Update READMEgr.md

* Update README.md

* Update READMEde.md

* Update READMEes.md

* Update READMEfr.md

* Update READMEja.md

* Update READMEnl.md

* Update READMEru.md

* Update READMEtr.md

* Update READMEuk.md

* Update extension-description-store-greek.txt

* Update extension-description-store-greek.txt

* Delete CONTRIBUTINGgr.md
2025-07-13 18:47:56 +02:00
Dmitrii Selivanov 98b5ac7ca5 Merge pull request #1079
Korean Language support
2025-07-13 18:29:08 +02:00
Anarios 5eb07df2e0 Merge branch 'main' into fork/Backas03/main
# Conflicts:
#	README.md
2025-07-13 18:28:37 +02:00
Juan Carlos Estrada Alvarez 9f3faae202 Add localization to the tooltip-porcentage-mode fields with the Spanish translation (#1090)
* Add localization to the tooltip-porcentage-mode fields, as well the Spanish translation

* Fix Spanish translation for showTooltipPercentage

* Fix plural Spanish translation
2025-07-13 18:18:59 +02:00
Dmitrii Selivanov c92d5896eb Merge pull request #1107
Adding the Chinese translation to FAQ, contributing, security md
2025-07-13 18:18:04 +02:00
Anarios 86aeb6eaf2 Merge branch 'main' into fork/jack-chan-123/main
# Conflicts:
#	CONTRIBUTING.md
#	CONTRIBUTINGde.md
#	CONTRIBUTINGfr.md
#	CONTRIBUTINGgr.md
#	CONTRIBUTINGnl.md
#	CONTRIBUTINGpl.md
#	CONTRIBUTINGpt_BR.md
#	CONTRIBUTINGru.md
#	CONTRIBUTINGtr.md
#	CONTRIBUTINGuk.md
#	Docs/FAQ.md
#	Docs/FAQde.md
#	Docs/FAQfr.md
#	Docs/FAQnl.md
#	Docs/FAQpl.md
#	Docs/FAQpt_BR.md
#	Docs/FAQru.md
#	Docs/FAQtr.md
#	Docs/FAQuk.md
#	Docs/SECURITY-FAQ.md
#	Docs/SECURITY-FAQde.md
#	Docs/SECURITY-FAQfr.md
#	Docs/SECURITY-FAQnl.md
#	Docs/SECURITY-FAQpl.md
#	Docs/SECURITY-FAQpt_BR.md
#	Docs/SECURITY-FAQru.md
#	Docs/SECURITY-FAQtr.md
#	Docs/SECURITY-FAQuk.md
#	Docs/readme.md
#	Docs/readmefr.md
#	Docs/readmenl.md
#	Docs/readmept_BR.md
#	Docs/readmetr.md
2025-07-13 18:17:10 +02:00
Anarios 2844720d10 Merge branch 'main' into fork/NaufalK25/main
# Conflicts:
#	CONTRIBUTING.md
#	CONTRIBUTINGde.md
#	CONTRIBUTINGfr.md
#	CONTRIBUTINGgr.md
#	CONTRIBUTINGnl.md
#	CONTRIBUTINGpl.md
#	CONTRIBUTINGpt_BR.md
#	CONTRIBUTINGru.md
#	CONTRIBUTINGtr.md
#	CONTRIBUTINGuk.md
#	Docs/FAQ.md
#	Docs/FAQde.md
#	Docs/FAQfr.md
#	Docs/FAQnl.md
#	Docs/FAQpl.md
#	Docs/FAQpt_BR.md
#	Docs/FAQru.md
#	Docs/FAQtr.md
#	Docs/FAQuk.md
#	Docs/SECURITY-FAQ.md
#	Docs/SECURITY-FAQde.md
#	Docs/SECURITY-FAQfr.md
#	Docs/SECURITY-FAQnl.md
#	Docs/SECURITY-FAQpl.md
#	Docs/SECURITY-FAQpt_BR.md
#	Docs/SECURITY-FAQru.md
#	Docs/SECURITY-FAQtr.md
#	Docs/SECURITY-FAQuk.md
#	README.md
#	READMEcn.md
#	READMEda.md
#	READMEde.md
#	READMEes.md
#	READMEfr.md
#	READMEgr.md
#	READMEhu.md
#	READMEja.md
#	READMEnl.md
#	READMEpl.md
#	READMEpt_BR.md
#	READMEru.md
#	READMEsv.md
#	READMEtr.md
#	READMEuk.md
2025-07-13 17:44:42 +02:00
Sidchou cb5838cf62 Update README.md (#1075) 2025-07-13 17:02:46 +02:00
dependabot[bot] 43dd3b6824 Bump core-js from 3.19.1 to 3.44.0 in /Website (#1165)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.19.1 to 3.44.0.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.44.0/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-version: 3.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 17:01:29 +02:00
hannafady db0a2e71a8 Add Arabic language links to FAQ and contributing documents (#1112)
Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2025-07-13 17:01:18 +02:00
dependabot[bot] f619fcf68f Bump vuetify from 2.6.0 to 3.9.0 in /Website (#1162)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 2.6.0 to 3.9.0.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.9.0/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-version: 3.9.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:55:55 +02:00
Charles Hall 7c3d8dac68 Fixed typo (#1110) 2025-07-13 16:55:40 +02:00
dependabot[bot] fa308ce07d Bump nuxt from 2.15.8 to 3.17.6 in /Website (#1163)
Bumps [nuxt](https://github.com/nuxt/nuxt/tree/HEAD/packages/nuxt) from 2.15.8 to 3.17.6.
- [Release notes](https://github.com/nuxt/nuxt/releases)
- [Commits](https://github.com/nuxt/nuxt/commits/v3.17.6/packages/nuxt)

---
updated-dependencies:
- dependency-name: nuxt
  dependency-version: 3.17.6
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:54:52 +02:00
Mika Reich f43ca3b865 Update FAQde.md (#1117)
In German, the noun 'Backend' has a neuter gender and would be used with 'das'.
2025-07-13 16:54:33 +02:00
dependabot[bot] 9c5a4b8b06 Bump lint-staged from 15.5.2 to 16.1.2 (#1166)
Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 15.5.2 to 16.1.2.
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v15.5.2...v16.1.2)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 16.1.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:54:03 +02:00
dependabot[bot] 0206a8e7e2 Bump filemanager-webpack-plugin from 8.0.0 to 9.0.1 (#1164)
Bumps [filemanager-webpack-plugin](https://github.com/gregnb/filemanager-webpack-plugin) from 8.0.0 to 9.0.1.
- [Release notes](https://github.com/gregnb/filemanager-webpack-plugin/releases)
- [Changelog](https://github.com/gregnb/filemanager-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gregnb/filemanager-webpack-plugin/compare/v8.0.0...v9.0.1)

---
updated-dependencies:
- dependency-name: filemanager-webpack-plugin
  dependency-version: 9.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:53:49 +02:00
dependabot[bot] 08471f1d32 Bump babel-loader from 9.2.1 to 10.0.0 (#1161)
Bumps [babel-loader](https://github.com/babel/babel-loader) from 9.2.1 to 10.0.0.
- [Release notes](https://github.com/babel/babel-loader/releases)
- [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel-loader/compare/v9.2.1...v10.0.0)

---
updated-dependencies:
- dependency-name: babel-loader
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:53:29 +02:00
dependabot[bot] b1e66e3ad8 Bump webpack-cli from 5.1.4 to 6.0.1 (#1160)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 5.1.4 to 6.0.1.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@5.1.4...webpack-cli@6.0.1)

---
updated-dependencies:
- dependency-name: webpack-cli
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:53:21 +02:00
dependabot[bot] 55a21ad85e Bump husky from 8.0.3 to 9.1.7 (#1159)
Bumps [husky](https://github.com/typicode/husky) from 8.0.3 to 9.1.7.
- [Release notes](https://github.com/typicode/husky/releases)
- [Commits](https://github.com/typicode/husky/compare/v8.0.3...v9.1.7)

---
updated-dependencies:
- dependency-name: husky
  dependency-version: 9.1.7
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:53:07 +02:00
dependabot[bot] 1592751376 Bump eslint from 7.32.0 to 9.31.0 in /Website (#1158)
Bumps [eslint](https://github.com/eslint/eslint) from 7.32.0 to 9.31.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v7.32.0...v9.31.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:52:53 +02:00
dependabot[bot] 995229fc2e Bump @nuxtjs/vuetify from 1.12.1 to 1.12.3 in /Website (#1157)
Bumps [@nuxtjs/vuetify](https://github.com/nuxt-community/vuetify-module) from 1.12.1 to 1.12.3.
- [Release notes](https://github.com/nuxt-community/vuetify-module/releases)
- [Changelog](https://github.com/nuxt-community/vuetify-module/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nuxt-community/vuetify-module/compare/v1.12.1...v1.12.3)

---
updated-dependencies:
- dependency-name: "@nuxtjs/vuetify"
  dependency-version: 1.12.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:52:35 +02:00
dependabot[bot] b70ca93301 Bump jest-environment-jsdom from 28.1.3 to 30.0.4 (#1167)
Bumps [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) from 28.1.3 to 30.0.4.
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.0.4/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: jest-environment-jsdom
  dependency-version: 30.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-13 16:52:05 +02:00
N1ck120 129ba619e8 Update Brazilian translations (#1120)
* Update CONTRIBUTINGpt_BR.md

Update Brazilian Contribution guide and sync with the main guide

* Update CONTRIBUTINGpt_BR.md

* Create extension-description-store-Portuguese.txt

* Update READMEpt_BR.md
2025-07-13 16:51:33 +02:00
blu3 a748ffeb7e Setup Dependabot and update actions (#1121)
* Update build action

* Update comment action

* Update website linter

* Setup Dependabot
2025-07-13 16:36:53 +02:00
Charles Hall 04aec7be69 Better README wording. (#1122)
* Better wording in README.

* Update README.md
2025-07-13 15:03:21 +02:00
avencores 4fae69734c addition of Russian translation (#1125) 2025-07-13 14:49:54 +02:00
Hacıağa Sadıxov 9205727284 Azerbaijani-az-language (#1126) 2025-07-13 14:49:07 +02:00
skatecram 29c8e7b173 Fixed styling for settings (#1131) 2025-07-13 14:41:03 +02:00
sami155 be303203a9 Updated german translation (#1119)
* Updated german translation

fixed translations and grammar

* Update messages.json

Typo
2025-07-13 13:34:00 +02:00
dtdan03 ce2ad7094d Improve German README (#1144)
- Corrected spelling and grammar
- Improved wording and sentence structures
- make it sound more natural

(Hint: "Rate limit" stays in English as an anglicism → you do not translate the technical term imho)
2025-07-13 13:32:53 +02:00
StackXP 0e4646e8f6 Improve German website translation (#1150)
Multiple fixes, including:
- uniform way of saying "you" (changed it to informal)
- changing "Creator" to "Youtuber" (idk the accusative of creator)
- capitalizing nouns
- general translation errors
2025-07-13 13:32:08 +02:00
Snow fc0b0feaa2 fix: sendVote infinitely looping (#1155) 2025-07-13 13:29:34 +02:00
Muhammad Naufal Kateni abb9d0359b Merge branch 'main' into main 2025-01-30 09:33:05 +07:00
Dmitrii Selivanov 49f0201805 Pr/fernandodilland translation of contributing.md into native spanish (#1115)
* Update CONTRIBUTING.md

[Español](CONTRIBUTINGes.md) on title

* Update CONTRIBUTINGfr.md

* Update CONTRIBUTINGnl.md

* Update CONTRIBUTINGru.md

* Update CONTRIBUTINGtr.md

* Update CONTRIBUTINGuk.md

* Create CONTRIBUTINGes.md

* Update READMEes.md

* Update READMEes.md

* Update CONTRIBUTINGes.md

* Update READMEes.md

* Update READMEja.md

* Update READMEtr.md

* Update READMEtr.md

* Update READMEgr.md

* Update extension-description-store-spanish.txt

* Update READMEes.md

---------

Co-authored-by: Fernando Dilland <fernandodilland@gmail.com>
2025-01-13 00:55:20 +01:00
Francesco 46e451600a Add Italian translation for website and update Italian translation for extension (#911)
* Add files via upload

* Delete messages.json

* Create messages.json

* Update messages.json

* Update messages.json
2025-01-13 00:44:32 +01:00
Dmitrii Selivanov 0d23402f1a Pr/949 (#1114)
* Add German Website Translation
New additions:
+ Added the 'de.ts' file
Changes:
- added language support for German to 'default.vue'
- added my name to the Translator list in 'links.vue'
- added German translation file to the imports and lang list in 'nuxt.config.js'

* Added name of the Korean Translator
Changes:
- added a comment with the name of the Translator to 'ko.ts'
- added the name of the Translator to the translator list in 'links.vue'

---------

Co-authored-by: Jörn <74071544+tubyoub@users.noreply.github.com>
Co-authored-by: Jörn <github@tubyoub.de>
2025-01-12 15:27:23 +01:00
Anarios 35b6ec0909 Link updated, version bump 2025-01-12 15:11:19 +01:00
Muhammad Naufal Kateni 18c680e250 remove indonesian link in CONTRIBUTINGid.md 2024-12-04 22:49:41 +07:00
Muhammad Naufal Kateni 86dfaa1449 add indonesian translation 2024-12-04 22:41:59 +07:00
jack-chan-123 427a49aa78 Adding the Chinese translation to FAQ, contributing, security md and update the path 2024-12-02 15:41:34 +01:00
Snow 30213b9ecf Unobserved child node removals (#1096)
* fix(userscript): listen to node removals

* fix(combined): observe node removals
2024-10-16 01:27:27 +02:00
Jake Anto 928d6df4c7 Fix Chrome Web Store link (#1092) 2024-10-08 18:10:02 +02:00
Anarios c8f23b2c7d #1074 - Extension is freezing some Youtube pages
#846 - Infinite Recursion on Some Video
2024-08-26 11:42:20 +02:00
Backas03 cf26dd6095 Update READMEkr.md 2024-08-21 13:13:38 +09:00
Backas03 65e61453f9 Update README.md 2024-08-21 13:13:08 +09:00
Backas03 971e682ff9 Update READMEkr.md 2024-08-21 13:11:51 +09:00
Backas03 e7aab61eed Update READMEkr.md 2024-08-21 13:11:36 +09:00
Backas03 6caa654b76 Update READMEkr.md 2024-08-21 11:58:52 +09:00
Backas03 cdf58e72a1 Update READMEkr.md 2024-08-21 11:43:46 +09:00
Backas03 f31d1d4143 Create READMEkr.md 2024-08-21 11:43:24 +09:00
Backas03 f20dba6453 Update README.md 2024-08-21 11:41:55 +09:00
Dmitrii Selivanov a42df5bb82 Update README.md 2024-06-16 22:31:47 +02:00
Fredrik Sejr Jørgensen 42c283755a Added a Danish version of the README (#885)
* Added link to Danish README

* Initialized Danish README

* Added Danish translation of README

---------

Co-authored-by: Anarios <selivano.d@gmail.com>
2024-06-16 22:11:44 +02:00
Eryk Darnowski 08ee253c05 Fix like to dislike bar appearing misaligned (#835)
* Fix CSS

* Implement more accurate method for getting like + dislike btn widths
2024-06-16 20:39:56 +02:00
officiallor 7d2fe5019c Add Greek translation (#899)
* Add Greek translation

* Corrected typos in the file

* Add a missed line

* Used better words

---------

Co-authored-by: l.tasi <l.tasi@msensis.com>
2024-06-16 20:36:27 +02:00
lonelil 2628ff4420 Added uYou+ and CercubePlus to install page (#750)
Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2024-06-16 20:32:18 +02:00
Pavel d2f334b9bc Make select elements have a dark background (#746)
Co-authored-by: Anarios <selivano.d@gmail.com>
2024-06-16 20:30:06 +02:00
KuryKat 7ddc193a18 Update pt-br localization - Improving translation (#701)
* Update pt-br localization

* Fixes after merge conflicts

---------

Co-authored-by: Anarios <selivano.d@gmail.com>
2024-06-16 20:27:26 +02:00
Gergely Pap bbf29fb2c2 Add Hungarian localization (#692)
* Translate readme to Hungarian

* Translate extension description to Hungarian

* Add Hungarian localization for website

* Add Hungarian locale to the extension

* Fixes after merge conflicts

---------

Co-authored-by: Gergely Pap <pgege42@gmail.com>
Co-authored-by: Anarios <selivano.d@gmail.com>
2024-06-16 20:20:40 +02:00
Leon Bubova d62130b898 Feature/add unit testing framework and start writing unit tests (acceptance tests) (#708)
* add jest and rewire for testing

* wrap loose code for initMutationObserver() in function and call it instead of just executing it

Otherwise this code is executed everytime state.js is imported somewhere in a file.
This leads to not being able to test properly, since state.js is imported in a lot
of the src files and fails when running the tests since we are missing global environment
settings such as document, window etc in the jest environment.

* export roundDown and getNumberformatter to make them testable as well

* add basic acceptance tests for utils.js, create snapshots and add jest config

* add failing test for buggy behaviour in a separate test case and skip it until fixed

* Fixes after merge conflicts

---------

Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2024-06-16 19:52:58 +02:00
Divyansh Agrawal fbf00e474d Added Option to Disable Logging By Default (#474)
* Added Option to Disable Logging By Default

* Added Userscript Support to Disable Logging

* Formatting and Fixed Compiling

* Updated Description of Setting Option

* Whitespace 😅

* Dot in Description of Setting option

* Typo in JAVASCRIPT
2024-06-16 18:52:41 +02:00
Kenny 3d788b3d31 Update Popup UI to Material 3 (#986)
* basic retheme on homepage

* basic material 3 theme

* fix slider head
2024-06-16 18:39:08 +02:00
dmantione 1339af1378 Improve translation and fix spelling errors (#1021)
Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2024-06-16 18:27:19 +02:00
Moondarker bbf2269e5f Added build action (#1018) 2024-06-16 18:21:50 +02:00
Aliberk Sandıkçı 7c4058a351 change android client links (#1041)
vuetube abandoned and Newpipe fork has been moved to new repo
2024-06-16 14:30:14 +02:00
soos dfcc400868 Change "None existing" to "Non-existent" (#1048) 2024-06-16 14:24:47 +02:00
ThomasTech bf98563c17 fix: getVideoId() with clip (#1055) 2024-06-16 14:24:21 +02:00
But you didnt have to cut me off eda0aca9e9 Rename FAQpt_BRmd to FAQpt_BR.md (#1059)
FAQpt_BRmd was missing an extension dot causing it to display incorrectly.
2024-06-16 14:23:17 +02:00
Anarios 954e0a7caf package.json version bump 2024-04-18 14:05:58 +02:00
Anarios f1a2cd54c2 Userscript version bump 2024-04-18 13:14:21 +02:00
Enguerran P 1ed03f7cc9 Extension - New (spring 2024) UI support (#1045)
* Extension - new (spring 2024) UI support

Add new player selector for isVideoLoaded() (fixes #1043)

* Githook tasks for fd45f86f (in a separate commit for readability)
2024-04-18 13:06:57 +02:00
Anarios 2882cfd6e6 3.0.0.15 2024-04-18 09:28:18 +02:00
Enguerran P 63a8b2868b Userscript - New (spring 2024) UI support (#1046)
* Userscript - new (spring 2024) UI support

Add new player selector for isVideoLoaded() (fixes #1043)

* Githook tasks for 17f5bdf0 (in a separate commit for readability)
2024-04-18 09:12:44 +02:00
Carlos Luiz 27d97a8b5a Update to Brazillian portuguese (#1028)
* Updato Brazillian portuguese

* 000000000

---------

Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2024-02-25 17:29:12 +01:00
Artis 6e15426637 Add 100% german translation (#1034)
* Add german FAQ translation

* Add german Security-FAQ translation

* Add missing german messages translation

* Add german Contributing translation

* Fix german README translation

* Add german extension description translation

* Add german README translation

* Add german README store translation

* Add german as language
2024-02-25 17:16:47 +01:00
Dmitrii Selivanov bdf45878a8 Update README.md 2024-01-22 09:53:09 +01:00
Anarios 0608077614 Prettier line width config 2024-01-16 01:04:11 +01:00
Moondarker 7da80768dd Implemented centralized versioning (#1016) 2024-01-15 09:10:35 +01:00
Anarios b133adeecb Prettier applied to whole project, prettier added to pre-commit hook 2024-01-15 01:39:46 +01:00
Anarios 47517ce0bd Testing prettier commit hooks 2024-01-14 21:41:37 +01:00
Anarios fbf80ab174 3.0.0.14 release - YouTube UI fixes 2023-12-12 23:25:17 +01:00
Moondarker 6791543617 Extension fix - disappearing dislike counter (#999)
* Fix rate bar jumping around in width

* Fix disappearing dislike counter

* Fix disappearing dislike count on button focus/unfocus by @kokonguyen191

* Fix event listeners being thrown away by YouTube in some cases

* Extension fix for disappearing dislikes while smartimations play

* Check if smartimationContainer exists
2023-12-12 19:33:37 +01:00
Moondarker 2f2153ed37 Userscript fix - disappearing dislike counter (#1000)
* Fix disappearing dislike counter - userscript

* Fix rate bar in userscript, disable it by default (it has been broken for ages)

* Fix event listeners being thrown away by YouTube in some cases

* Fix disappearing dislike count on button focus/unfocus

* Remove repetitive code from the userscript

* Userscript fix for disappearing dislikes while smartimations play

* Check if smartimationContainer exists

* Properly handle non-existing dislike button

In some cases, dislike button doesn't exist when we're trying to
interact with it. For end users, it manifested as dislike counter
appearing on both dislike button and share button (because
getDislikeButton was falling back to the second child of getButtons()).
I don't see an elegant way of fixing this without creating a fake button
in DOM specifically for this case or rewriting the whole userscript.
Ignoring this would result in errors appearing in console.

---------

Co-authored-by: Felix Nguyen <kokonguyen191@gmail.com>
2023-12-12 19:30:17 +01:00
Moondarker 614c33376d Rephrased an important question (#1011) 2023-12-12 19:29:20 +01:00
Anarios b6367ae334 3.0.0.13 release - dynamic selectors config loading from server added. 2023-12-04 11:04:14 +01:00
Khang f78bd7708a Added Vietnamese translation.
Added Vietnamese Docs/readme translation.

Added links to the Vietnamese Docs/readme in the other Docs/readme translations.

Added Vietnamese Privacy Policy translation.

Added Vietnamese FAQ translation.

Added links to the Vietnamese FAQ in the other FAQ translations.

Added Vietnamese SECURITY-FAQ translation.

Added links to the Vietnamese FAQ in the other SECURITY-FAQ translations.

Added placeholder for Vietnamese Guide__Installing translation.

Added placeholder for Vietnamese Guide__Troubleshooting translation.

Fixed typos.
2023-12-02 23:23:45 -06:00
Khang 3ff6ce1209 Added Vietnamese translation.
Added Vietnamese translation for extension.
2023-12-02 16:57:02 -06:00
Khang 4ea103c745 Added Vietnamese translation.
Added Vietnamese translation for website.

Fixed typo.

Changed word used in Vietnamese translation.
2023-12-02 16:41:45 -06:00
Khang 6f89e4d22a Added Vietnamese translation.
Initial Vietnamese translation.

Added Vietnamese README.

Added links to the Vietnamese README in the other README translations.

Added Vietnamese CONTRIBUTING.

Added links to the Vietnamese CONTRIBUTING in the other CONTRIBUTING translations.

Added Vietnamese extension-description-store.
2023-12-02 03:23:40 -06:00
Khang 4650337f8e Fixed typo and Updated link to API docs. 2023-12-02 01:21:40 -06:00
Dmitrii Selivanov 0105c6c141 Update manifest-chrome.json 2023-12-01 13:21:39 +01:00
Dmitrii Selivanov fc76589e77 Update manifest-firefox.json 2023-12-01 13:21:25 +01:00
Moondarker 1e2935b2fa Extension fix for the latest YouTube update (#991)
* Extension fix for the latest YouTube update

* Bump version to reflect real state of things
2023-12-01 10:51:15 +01:00
Moondarker c4961aeb4b Userscript fix for the latest YouTube update (#992) 2023-12-01 10:49:55 +01:00
itsbudyn a6289794d2 Add Polish translation (#884)
* initial polish translation

* add polish translation files

* add missing space

* improved pl extension translations

* improved translations

* website translation

* fixed typos

* add link to polish on READMEgr

* don't litteraly translate endpoints

---------

Co-authored-by: itsbudyn <kuba@m3.perkele.int>
Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2023-11-05 18:52:13 +01:00
Starter da77fe9a2f Typo Fix (#963) 2023-11-05 18:47:03 +01:00
Anarios 6239453b9d Big ternary replaced with if-else 2023-11-05 18:30:57 +01:00
Marcel Guzik adbd0fd947 don't show dislike count on share button - fixes #948 (#978) 2023-11-05 18:29:24 +01:00
Anarios 2c7dd8d8ca New YouTube UI fix - 3.0.0.12 2023-10-18 11:46:37 +02:00
Anarios fdf3c7fc11 New YouTube UI fix 2023-10-12 22:12:51 +02:00
Anarios ccbc9a71b4 New YouTube UI fix 2023-10-12 22:05:55 +02:00
Zigao Wang c965d73abd Added Chinese Translation (#936)
* Added Chinese Translation

* Added Chinese Translation

* Update READMEde.md

* Update READMEes.md

* Update READMEfr.md

* Update READMEgr.md

* Update READMEja.md

* Update READMEnl.md

* Update READMEru.md

* Update READMEtr.md

* Update READMEuk.md

* Create extension-description-store-Chinese.txt

---------

Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2023-09-30 13:14:54 +02:00
umerf52 15caa3b141 Update README.md (#903)
Fix typo in README
2023-09-30 13:05:28 +02:00
AminoffZ b49114bb1b 🌲 Swedish README (#937)
* 🌲 Swedish README

Added a Swedish README following the format.

* 🔗 Add Links

Add links to the Swedish README in the other translations.
2023-09-30 13:04:48 +02:00
Ilyas Zan f18be54de1 Turkish translation errors fixed (#940) 2023-09-30 13:01:41 +02:00
Anarios 8f879dbd25 New YouTube UI fix 2023-08-02 14:01:30 +02:00
Anarios 889089ad1d New YouTube UI fix 2023-08-02 14:00:40 +02:00
Anarios 5c73825aad New YouTube UI fix for userscript and extension 2023-05-10 10:47:27 +02:00
Theofanis Sarmidis 2cb137ddf6 Updated locale for Greek language to correct 'el'. Create READMEgr.md file (#894)
* Create READMEgr.md

* Create CONTRIBUTINGgr.md

* Create extension-description-store-greek.txt

* Rename Extensions/combined/_locales/gr/messages.json to Extensions/combined/_locales/el/messages.json

* Update messages.json

correct locale for the GREEK language is 'el'

* Update READMEgr.md

* Update READMEgr.md

* Update READMEgr.md

* Update README.md

* Update READMEde.md

* Update READMEes.md

* Update READMEfr.md

* Update READMEja.md

* Update READMEnl.md

* Update READMEru.md

* Update READMEtr.md

* Update READMEuk.md

* Update extension-description-store-greek.txt

* Update extension-description-store-greek.txt

* Delete CONTRIBUTINGgr.md
2023-04-02 14:32:13 +02:00
Suhail Khan f680bf6d81 Add Safari extension build target (#896) 2023-04-02 14:17:18 +02:00
Anarios 0c39488852 Styling, version bump, options popup 2023-03-11 21:56:07 +01:00
Roman Ushakov a6c945a4eb fix get count dislikes on mobile version of youtube (#888)
* fix (ryd.content.script.js, events.js) fix get count likes on mobile version of yt (tested on android device)

* fix fallback solution for get dislikes
2023-03-10 19:40:57 +01:00
sy-b 9a44acf89d Show dislikes again on new YT UI (01) (2) (#886)
Signed-off-by: sy-b <94835959+sy-b@users.noreply.github.com>
2023-03-10 19:38:11 +01:00
Anarios 931006a00b Revert "added margin for the dislike text (#808)"
This reverts commit 8802b9c76f.
2023-02-18 18:49:20 +01:00
Baraa Halabi 8802b9c76f added margin for the dislike text (#808)
Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2023-02-18 18:45:38 +01:00
JxxIT a2490de5fe Add Dutch translation. (#768)
* Add Dutch translation.

* Fixed the typos. Thanks to @YassineGherbi

---------

Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2023-02-18 18:43:42 +01:00
Karole Godin 557947d32d new translation for READMEfr.ld and extension-description-store-french.txt (#844)
* new translation for READMEfr.ld and extension-description-store-french.txt

* French translation on website

* Dislike margin

---------

Co-authored-by: Dmitrii Selivanov <selivano.d@gmail.com>
2023-02-18 18:02:30 +01:00
Moondarker cf10eeaad3 Create dislike text container based on existing style classes (#793) 2023-02-18 17:40:54 +01:00
Mohit Arora 6d441a2932 Dislike text disabled on click fix. (#828)
The dislike number text got disabled whenever we clicked on the dislike button, it was because the addEventListener wasn't being attached to the correct like and dislike buttons (new).

Tested in chrome windows 11, version: 107.0.5304.107
2023-02-18 17:38:01 +01:00
Wuffli cbfd4bcdbf Added a german version of the README (#863)
* Initialized german README

Added README.md to translate from english version.
Added links to the german version on all other versions.

* 1. Translation draft.

First Translation draft done ready for proofreading.

* Added missing Links

Links to: English version, Chrome web store, Firefox Browser Add-ons.

* Fixed links

* Some spelling corrections
2023-02-18 17:37:18 +01:00
Oleksandr 0f5cab74b0 Fixed few typos in Ukrainian (#877)
* Fixed typos

* Fixed typos

* Fixed typos
2023-02-18 17:36:20 +01:00
1nterp 75a4fd51d6 Increase text left margin (#827) 2023-02-18 14:09:07 +01:00
Liberal dev 7d6e3a14c8 Add Korean translation (#865)
* Fix "ReferenceError: uk is not defined"

* Add Korean language support to website

* Korean translation of the webpage completed.

* Added Korean translation for the extension.
2023-02-18 14:06:10 +01:00
Dmitrii Selivanov 45734421df Merge pull request #836 from ErykDarnowski/bug/790/fix-color-themes-have-double-quotes
Fix color themes have double quotes
2023-02-18 11:55:46 +01:00
Dmitrii Selivanov 3276eb7474 Update popup.html
fixed tabulation
2023-02-18 11:55:20 +01:00
Dmitrii Selivanov 0b64811292 Merge pull request #876 from Anarios/revert-874-UIDislikeButtonFix01
Revert "Show dislikes again on new YT UI (01) & fix workflow faliure"
2023-02-18 11:15:12 +01:00
Dmitrii Selivanov a4c763d586 Revert "Show dislikes again on new YT UI (01) & fix workflow faliure" 2023-02-18 11:14:58 +01:00
Dmitrii Selivanov 4a7615cb20 Merge pull request #829 from hyperstown/fix_disappearing_rate_bar
Fix disappearing rate bar
2023-02-18 11:08:10 +01:00
Dmitrii Selivanov bcf8288584 Merge pull request #858 from prestancedesign/patch-1
Fix translate typo
2023-02-18 11:00:21 +01:00
Dmitrii Selivanov cf74865d4b Merge pull request #872 from grantwforsythe/main
Fix 404's in READMEs
2023-02-18 10:57:09 +01:00
Dmitrii Selivanov 4fc7740ea1 Merge pull request #874 from sy-b/UIDislikeButtonFix01
Show dislikes again on new YT UI (01) & fix workflow faliure
2023-02-18 10:56:31 +01:00
sy-b 3f2a552214 fix typo causing weblint workflow to fail
fixed
```
12 /home/runner/work/return-youtube-dislike/return-youtube-dislike/Website/nuxt.config.js
13    8:8   error  'de' is defined but never used  no-unused-vars
14   54:46  error  'uk' is not defined             no-undef
```

Signed-off-by: sy-b <94835959+sy-b@users.noreply.github.com>
2023-02-16 18:19:38 +05:30
sy-b 44d1ea390f Show dislikes again on new YT UI (01)
Signed-off-by: sy-b <94835959+sy-b@users.noreply.github.com>
2023-02-16 17:54:12 +05:30
Grant Forsythe f93410def1 Update READMEs 2023-02-14 08:51:12 -05:00
Michael Salihi 5926b213d6 Fix translate typo 2022-12-29 18:18:08 +01:00
ErykDarnowski 684a882cd6 Remove double quotes 2022-11-26 02:25:01 +01:00
hyperstown 229be807a1 remove cLog 2022-11-23 11:59:19 +01:00
hyperstown b5382bab78 improve rateBar selector 2022-11-23 11:52:00 +01:00
hyperstown 7db9d9d30d Revert "fix disappearing rate bar"
This reverts commit 8eef50918d.
2022-11-23 11:20:38 +01:00
hyperstown 8eef50918d fix disappearing rate bar 2022-11-22 20:51:51 +01:00
Dmitrii Selivanov d12539d3bb Merge pull request #749 from hyperstown/main
Add support for new youtube layout in UserScript
2022-11-18 21:24:50 +01:00
Dmitrii Selivanov f1732d13d8 Merge pull request #816 from hyperstown/fix_display_error
Fix "API down" error message
2022-11-18 17:32:47 +01:00
Dmitrii Selivanov a67c98cd33 Merge pull request #817 from stilian-iliev/main
Fixed typo on line 84
2022-11-18 17:32:08 +01:00
Stilian Iliev c919a2511f Fixed typo on line 84 2022-11-11 01:13:56 +02:00
Dmitrii Selivanov 84ede192b4 Merge pull request #728 from rakleed/patch-1
Fix typos and writing style in `READMEru.md`
2022-11-10 15:13:28 +01:00
Dmitrii Selivanov 9fd876fb43 Merge pull request #726 from sy-b/patch-1
Dislike formula inverted -> Correction
2022-11-10 15:11:40 +01:00
Dmitrii Selivanov 2ef6c9b014 Merge pull request #747 from entitymob/patch-1
Update SECURITY-FAQfr.md
2022-11-10 15:11:22 +01:00
Dmitrii Selivanov fbc5370e68 Merge pull request #766 from Larry-the-Pig/main
fix typo
2022-11-10 15:10:50 +01:00
Dmitrii Selivanov e971c0da50 Merge pull request #771 from ErykDarnowski/fix-like-dislike-bar-offset
Fix like/dislike ratio bar offset
2022-11-10 15:10:12 +01:00
Dmitrii Selivanov 06f0a8b197 Merge branch 'main' into fix-like-dislike-bar-offset 2022-11-10 15:09:56 +01:00
Dmitrii Selivanov aa10f30a70 Merge pull request #783 from hyperstown/re-format-numbers-fix
Fix reformat likes
2022-11-10 15:06:59 +01:00
Dmitrii Selivanov 033e111023 Merge pull request #785 from KashBoi7/main
Grammar Corrections to the Website (english)
2022-11-10 15:05:22 +01:00
Dmitrii Selivanov e084ff3468 Merge pull request #787 from Sopor/patch-2
Add Swedish translation for Website
2022-11-10 15:04:50 +01:00
Dmitrii Selivanov e57732e257 Merge pull request #788 from Sopor/patch-1
Add Swedish translation
2022-11-10 15:04:21 +01:00
Dmitrii Selivanov 861342a95c Merge pull request #796 from MeDustyy/main
Ukrainian translation of almost all files
2022-11-10 15:03:53 +01:00
Dmitrii Selivanov b5c7b519c2 Merge pull request #803 from Moondarker/better-bug-report
GitHub Issues: Ensuring a bug report is not a duplicate on the first stage
2022-11-10 15:00:46 +01:00
Dmitrii Selivanov 7e2e95feeb Merge pull request #804 from mini-bomba/new-layout-fix
Fix ratiobar positioning in the new youtube design
2022-11-10 15:00:08 +01:00
Dmitrii Selivanov f1891e0dbb Merge pull request #814 from thatrobotdev/patch-1
Fix typo (it's -> its) in FAQ.md
2022-11-10 14:58:46 +01:00
hyperstown d719f4b5c9 fix display error 2022-11-08 19:46:41 +01:00
James Kerrane 7acb3be26d Fix typo (it's -> its) in FAQ.md 2022-11-06 19:42:26 -07:00
mini-bomba 02cb6fd398 Fix ratiobar positioning in the new youtube design
This is done by attaching the bar to the like/dislike button container instead of the whole interaction menu,
then using absolute positioning after modifying a few youtube styles.
2022-11-01 16:57:32 +01:00
Moondarker 403f58d4a2 Added an important checkbox
Wanted to add it at the bottom at first, but then thought about potential time waste for those who file an issue and it ends up being a duplicate
2022-11-01 18:15:45 +03:00
Oleksandr 93aabc527e Аunctionality to select the language 2022-10-29 20:29:55 +03:00
Oleksandr 14780aac55 Ukrainian translator 2022-10-29 20:27:30 +03:00
Oleksandr c8e3e87077 Functionality to select the language 2022-10-29 20:24:07 +03:00
Oleksandr c4a150f6de Link to the Ukrainian translation 2022-10-28 21:48:36 +03:00
Oleksandr e6c20d85f0 Link to the Ukrainian translation 2022-10-28 21:48:11 +03:00
Oleksandr 6ff63bdec5 Link to the Ukrainian translation 2022-10-28 21:47:54 +03:00
Oleksandr 7cdd4aaf06 Link to the Ukrainian translation 2022-10-28 21:47:22 +03:00
Oleksandr 3568b6ceaf Link to the Ukrainian translation 2022-10-28 21:46:42 +03:00
Oleksandr 951a72c574 Link to the Ukrainian translation 2022-10-28 21:46:25 +03:00
Oleksandr 8b8c09668e Link to the Ukrainian translation 2022-10-28 21:46:02 +03:00
Oleksandr 04f8ed584d Link to the Ukrainian translation 2022-10-28 21:45:49 +03:00
Oleksandr cfb1cadfd7 Add Ukrainian translation 2022-10-28 21:45:01 +03:00
Oleksandr 53f132a0f3 Add Ukrainian translation 2022-10-28 21:44:01 +03:00
Oleksandr 3da6ec71a5 Add Ukrainian translation 2022-10-28 21:42:37 +03:00
Oleksandr 32a59d4a16 Link to the Ukrainian translation 2022-10-28 21:42:03 +03:00
Oleksandr 1fef960253 Link to the Ukrainian translation 2022-10-28 21:41:47 +03:00
Oleksandr a075f751dc Link to the Ukrainian translation 2022-10-28 21:41:26 +03:00
Oleksandr 1291858a1e Link to the Ukrainian translation 2022-10-28 21:41:03 +03:00
Oleksandr aebc586be8 Link to the Ukrainian translation 2022-10-28 21:39:59 +03:00
Oleksandr d752904d98 Link to the Ukrainian translation 2022-10-28 21:39:32 +03:00
Oleksandr ef353ad383 Link to the Ukrainian translation 2022-10-28 21:39:09 +03:00
Oleksandr 2efeb127b0 Link to the Ukrainian translation 2022-10-28 21:38:42 +03:00
Oleksandr b5c37fc17c Link to the Ukrainian translation 2022-10-28 21:38:12 +03:00
Oleksandr e33c17970f Link to the Ukrainian translation 2022-10-28 21:37:52 +03:00
Oleksandr 3f56e5ecf0 Add Ukrainian translation 2022-10-28 21:37:05 +03:00
Sopor 7e14e6dd9b Add Swedish translation for Website 2022-10-24 04:46:25 +02:00
Sopor 51424d11cf Add Swedish translation 2022-10-24 03:15:29 +02:00
KashBoi7 a030e5e54e Update en.ts 2022-10-18 20:42:48 -07:00
KashBoi7 417f327938 Update en.ts 2022-10-18 20:40:58 -07:00
KashBoi7 27bdc3256b Update en.ts 2022-10-18 20:38:42 -07:00
KashBoi7 a41b0032d6 Update en.ts 2022-10-18 16:49:59 -07:00
KashBoi7 d796fa4460 Update
Grammar corrections for the Website
2022-10-18 16:37:55 -07:00
KashBoi7 53a24ec09b Update to help page
embeds a link into the chrome webstore firefox addon snippets and
2022-10-17 18:57:27 -07:00
hyperstown f1fe41e077 bump version 2022-10-18 00:40:21 +02:00
hyperstown 87e922d41f fix like reformat option to work with new design 2022-10-18 00:39:21 +02:00
ErykDarnowski b6bec07368 Fix comment 2022-10-09 01:20:24 +02:00
ErykDarnowski bf74407f8a Fix like/dislike ratio bar offset
Closes [#733](https://github.com/Anarios/return-youtube-dislike/issues/733)
2022-10-09 01:12:22 +02:00
hyperstown 69d4a68656 Merge branch 'Anarios:main' into main 2022-10-01 00:09:13 +02:00
Larry-the-Pig 5da54f2738 fix typo 2022-09-30 08:19:07 -06:00
Dmitrii Selivanov de1831f2c7 Merge pull request #759 from hyperstown/fix-hidden-count
QFIX hidden dislike count, extension & userjs
2022-09-21 12:50:10 +02:00
Dmitrii Selivanov 8f88f9203f Merge pull request #754 from thiagocferr/741-Dislike-Numbers-in-Shorts-disappears-when
HOTFIX: added aditional check for like/dislike buttons inside ACTIVE …
2022-09-21 12:49:37 +02:00
Dmitrii Selivanov 03c6f3b234 Merge pull request #761 from hyperstown/fix-new-design-getdislikecontainer
FIX: Dislike button displaying "Dislike" text next to count
2022-09-21 12:47:16 +02:00
hyperstown 805f693be3 fix getDislikeTextContainer for new design 2022-09-19 20:23:29 +02:00
hyperstown 9ecdfb5532 bump version 2022-09-18 12:47:19 +02:00
hyperstown 8c95d76d03 fix hidden dislike count, extension & userjs 2022-09-18 12:32:44 +02:00
Thiago Ferreira 11df4d1706 HOTFIX: added aditional check for like/dislike buttons inside ACTIVE Viewport
In certain cases (see issue #741), the like/dislike buttons would be considered IN the viewport, despite its parent elements being hidden and its DOMRect being 0 on all sides. This way, added an additional check for considering these "hidden" buttons as NOT in the Viewport.
2022-09-14 11:53:13 -03:00
hyperstown af4c0a16b4 fix wrong dislike button select 2022-09-10 19:20:30 +02:00
hyperstown 59e3dc98ee fix shorts number 2022-09-10 16:16:25 +02:00
hyperstown 06261eda37 fix dislike width 2022-09-10 15:27:33 +02:00
hyperstown 6a2806cbbe add support for new yt layout on mobile 2022-09-10 14:45:40 +02:00
hyperstown da184d75ff add support for new yt layout 2022-09-10 13:47:46 +02:00
entitymob 7c7f4f3fd0 Update SECURITY-FAQfr.md
Il convient d'avoir une terminaison en ez, car il s'agit de la deuxième personne du pluriel du verbe traquer.
2022-09-07 19:29:55 +02:00
Dmitrii Selivanov 9a7554ba3c Merge pull request #719 from ilbailba/main
Turkish translation additions and corrections / File translation references correction
2022-09-01 09:41:37 +02:00
Pavel Moiseenko 45f4c3f335 Fix typos and writing style 2022-08-17 18:12:12 +03:00
sy-b d25d10fcf9 Dislike formula inverted -> Correction
resolves Anarios/return-youtube-dislike/issues/725
2022-08-11 09:15:09 +01:00
Anarios 6277868ac7 Decreased manifest version to prevent "Update your extension" warnings 2022-08-05 23:34:09 +02:00
Anarios 6be44d9eb9 New layout fix - bar. 2022-08-05 23:03:36 +02:00
Anarios 2d9809ad31 New layout fix 2022-08-05 22:57:27 +02:00
Anarios 63673f3089 Prettier reformat 2022-08-05 21:24:05 +02:00
ilyas zan 88154c182e Turkish translation additions 2022-08-05 20:45:02 +03:00
ilyas zan 3f49b65e81 Turkish translations and Reference corrections 2022-08-05 19:29:41 +03:00
Anarios 4e0f633a14 Prettier reformat 2022-08-02 00:03:03 +02:00
Dmitrii Selivanov de0886bcd7 Merge pull request #710 from shadoxxhd/main
fix dislike text box not being found for some users
2022-08-02 00:00:14 +02:00
sh4dow 596c0d730c fix dislike text box not being found for some users 2022-08-01 21:49:35 +02:00
Dmitrii Selivanov dfa541ad1c Update manifest-chrome.json
Updated manifest to prevent extension from showing a wrong update message
2022-07-29 16:36:33 +02:00
Dmitrii Selivanov c0ba031eaf Create Privacy Policy 2022-07-29 01:31:13 +02:00
Anarios 61c3757d1f 3.0.0.3 2022-07-28 22:56:32 +02:00
Anarios 8e376f6142 RoundDown option removed 2022-07-28 22:18:42 +02:00
Dmitrii Selivanov 752edcc27f Merge pull request #694 from KendallDoesCoding/main
Optimize images
2022-07-26 00:40:41 +02:00
Dmitrii Selivanov 4e9441ae3f Merge pull request #699 from Anarios/develop
Many PRs merget, prettier applied globally.
2022-07-26 00:26:21 +02:00
Kendall e9e26d48bd Merge pull request #10 from KendallDoesCoding/imgbot
[ImgBot] Optimize images
2022-07-24 17:50:59 +05:30
Kendall c4f3b37a38 Merge pull request #16 from Anarios/main
[pull] main from Anarios:main
2022-07-24 17:50:37 +05:30
ImgBotApp df7fb18efa [ImgBot] Optimize images
*Total -- 503.38kb -> 442.44kb (12.11%)

/Website/raw_assets/ukraine-flag-xs.png -- 1.26kb -> 0.96kb (23.87%)
/Extensions/combined/changelog/images/colorize_example.jpg -- 9.63kb -> 8.21kb (14.74%)
/Extensions/combined/changelog/images/number_format.jpg -- 26.59kb -> 23.12kb (13.03%)
/Website/raw_assets/troubleshooting.png -- 465.90kb -> 410.15kb (11.97%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2022-05-10 18:44:45 +00:00
Kendall Pinto 1821cde934 Merge branch 'Anarios:main' into main 2022-05-08 15:35:25 +05:30
Kendall Pinto 8a3ef1bf11 delete as per request in PR 2022-05-08 15:35:19 +05:30
ImgBotApp cbed2bb2c6 [ImgBot] Optimize images
*Total -- 827.46kb -> 675.68kb (18.34%)

/Icons/Discord.gif -- 300.78kb -> 206.55kb (31.33%)
/Extensions/combined/icons/server.svg -- 1.09kb -> 0.92kb (16.07%)
/Website/static/ui/troubleshooting.png -- 465.90kb -> 410.15kb (11.97%)
/Website/static/ui/abstract.svg -- 5.19kb -> 4.97kb (4.31%)
/Icons/128x128_transparent.jpg -- 4.04kb -> 3.92kb (3%)
/Icons/Return Youtube Dislike - Dark.jpg -- 32.45kb -> 31.52kb (2.88%)
/Icons/Return Youtube Dislike - Dark.png -- 17.99kb -> 17.65kb (1.86%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2022-05-04 16:12:11 +05:30
325 changed files with 72087 additions and 20701 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"presets": ["@babel/preset-env"]
"presets": ["@babel/preset-env"],
"plugins": ["rewire"]
}
+3
View File
@@ -1,2 +1,5 @@
# Auto detect text files and perform LF normalization
* text=auto
# Generated output must remain byte-for-byte stable across platforms.
"Extensions/UserScript/Return Youtube Dislike.user.js" text eol=lf
+6
View File
@@ -3,6 +3,12 @@ description: File a bug report!
# title: "(Bug): "
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Have you tried to find similar issues (open or recently closed)?
options:
- label: "Yes, this issue is not a duplicate"
required: true
- type: input
attributes:
label: Browser
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/Website"
schedule:
interval: "daily"
+104
View File
@@ -0,0 +1,104 @@
name: Test Builds
on:
push:
branches:
- main
paths:
- "Extensions/combined/**"
- "Extensions/common/**"
- "Extensions/UserScript/**"
- "Extensions/e2e/**"
- ".github/workflows/build.yaml"
- ".babelrc"
- ".gitattributes"
- ".nvmrc"
- "jest.config.js"
- "package.json"
- "package-lock.json"
- "playwright*.js"
- "webpack*.js"
pull_request:
branches:
- main
paths:
- "Extensions/combined/**"
- "Extensions/common/**"
- "Extensions/UserScript/**"
- "Extensions/e2e/**"
- ".github/workflows/build.yaml"
- ".babelrc"
- ".gitattributes"
- ".nvmrc"
- "jest.config.js"
- "package.json"
- "package-lock.json"
- "playwright*.js"
- "webpack*.js"
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Jest tests
run: npm test -- --runInBand
- name: Build extension
run: npm run build
- name: Build userscript
run: npm run build:userscript
- name: Check generated userscript
run: git diff --exit-code -- "Extensions/UserScript/Return Youtube Dislike.user.js"
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run generated-artifact browser tests
run: npm run test:e2e:artifacts
- name: Run userscript browser tests
run: npx playwright test --config playwright.userscript.config.js
- name: Upload Chrome artifact
uses: actions/upload-artifact@v4
with:
name: ryd-chrome
path: ./Extensions/combined/dist/chrome
- name: Upload Firefox artifact
uses: actions/upload-artifact@v4
with:
name: ryd-firefox
path: ./Extensions/combined/dist/firefox
- name: Upload Safari artifact
uses: actions/upload-artifact@v4
with:
name: ryd-safari
path: ./Extensions/combined/dist/safari
- name: Upload Playwright failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: userscript-playwright-failure
path: |
./test-results
./playwright-report
if-no-files-found: ignore
retention-days: 7
-53
View File
@@ -1,53 +0,0 @@
name: commentCommands
on:
issue_comment:
types: created
jobs:
assign-commenter:
runs-on: ubuntu-latest
if: |
contains(github.event.comment.body, '/assignme') ||
contains(github.event.comment.body, '/assign me')
steps:
- name: Assigning to commenter
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/assignees \
-d '{"assignees":["${{ github.event.comment.user.login }}"]}'
request-issue-framing-improvement:
runs-on: ubuntu-latest
if: |
contains(github.event.comment.body,'/improve') && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'CONTRIBUTOR' )
steps:
- name: request-issue-framing-improvement
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \
-d '{"body":"This issue is put on hold due to low quality. No reviews or fixes will be performed at this time. Eventually, it will be closed. While we appreciate your effort writing, we are not able to further investigate it. Please improve it by writing a better title or providing more details, and you may re-open it."}'
add-label-duplicate:
runs-on: ubuntu-latest
if: |
contains(github.event.comment.body, '/duplicate') && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association != 'CONTRIBUTOR' )
steps:
- name: add-label-duplicate
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \
-d '{"labels":["duplicate"]}'
+57
View File
@@ -0,0 +1,57 @@
name: Comment Commands
on:
issue_comment:
types: [created]
jobs:
assign-commenter:
runs-on: ubuntu-24.04
if: |
contains(github.event.comment.body, '/assignme') ||
contains(github.event.comment.body, '/assign me')
steps:
- name: Assigning commenter
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/assignees \
-d '{"assignees":["${{ github.event.comment.user.login }}"]}'
request-issue-framing-improvement:
runs-on: ubuntu-24.04
if: |
contains(github.event.comment.body, '/improve') && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'CONTRIBUTOR'
)
steps:
- name: Request issue framing improvement
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \
-d '{"body":"This issue is put on hold due to low quality. No reviews or fixes will be performed at this time. Eventually, it will be closed. While we appreciate your effort writing, we are not able to further investigate it. Please improve it by writing a better title or providing more details, and you may re-open it."}'
add-duplicate-label:
runs-on: ubuntu-24.04
if: |
contains(github.event.comment.body, '/duplicate') && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association != 'CONTRIBUTOR'
)
steps:
- name: Add duplicate label
run: |
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \
-d '{"labels":["duplicate"]}'
+13 -7
View File
@@ -4,22 +4,28 @@ name: Website Lint
on:
push:
branches: [main]
branches:
- main
paths:
- Website/**
pull_request:
branches: [main]
branches:
- main
paths:
- Website/**
jobs:
weblint:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@main
- run: npm install
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm install
working-directory: Website
- run: npm run lint
- name: Run lint
run: npm run lint
working-directory: Website
+8 -2
View File
@@ -1,10 +1,13 @@
*cert
*Backend
.DS_Store
package-lock.json
# Website node modules and build output
Website/package-lock.json
# AI assistant artifacts
.claude/
CLAUDE.md
Website/.editorconfig
Website/node_modules
Website/.nuxt
@@ -36,5 +39,8 @@ Extensions/combined/bundled-content-script.js
# Dist Files
Extensions/combined/dist/*
package-lock.json
Website/package-lock.json
# Playwright output
/playwright-report/
/test-results/
+1
View File
@@ -0,0 +1 @@
v22.17.0
+1 -1
View File
@@ -1,4 +1,4 @@
Read this in other languages: [Français](CONTRIBUTINGfr.md)
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Welcome To Return YouTube Dislikes contributing guide
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# مرحبًا بكم في دليل المساهمة في Return YouTube Dislikes
شكرًا لك على استثمار وقتك في المساهمة في مشروعنا! سيتم عكس جميع تغييراتك في الإصدار التالي من الامتداد (أو [الموقع](https://www.returnyoutubedislike.com/)).
## البدء
يرجى استخدام Prettier بالإعدادات الافتراضية للتنسيق.
#### المتطلبات الأساسية
تحتاج إلى تثبيت node و npm لإنشاء النسخة المجمعة من المصدر.
الإصدارات المستخدمة عند الإعداد:
- node: 12.18.4
- npm: 6.14.6
لإنشاء `bundled-content-script.js` الذي يحتوي على معظم منطق العمل لهذا الامتداد، يجب عليك تثبيت جميع التبعيات أولاً.
1. انتقل إلى جذر المستودع وقم بتشغيل:
```
npm install
```
2. قم بتشغيل الأمر التالي لإنشاء `bundled-content-script.js` الذي يستخدم في `manifest.json`
```
npm start // لإنشاء ملف(ات) البناء وبدء مراقب الملفات الذي يعيد التحميل تلقائيًا عند الحفظ
// أو
npm run build // لإنشاء ملف(ات) البناء مرة واحدة
```
تهانينا، أنت الآن جاهز للتطوير!
إذا كنت جديدًا في تطوير ملحقات Chrome، أو تحتاج إلى مساعدة إضافية، يرجى مشاهدة [هذا الفيديو التعليمي على YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### المشاكل
#### فتح مشكلة جديدة
إذا كانت لديك أي مشاكل مع الامتداد، يرجى البحث للتأكد من أن المشكلة لم يتم الإبلاغ عنها بالفعل. إذا لم تكن كذلك، افتح مشكلة، يوصى بشدة باستخدام نموذج المشكلة ولكنه ليس إلزاميًا.
#### حل مشكلة
إذا وجدت مشكلة تشعر أنك قد تكون قادرًا على حلها، لا تخجل. افتح PR مع الإصلاح وتأكد من ذكر المشكلة التي تقوم بإصلاحها.
### طلب ميزة
#### فتح طلب ميزة جديدة
إذا كانت لديك فكرة للامتداد، لا تتردد في فتح طلب ميزة، ولكن يرجى البحث أولاً للتأكد من أن الميزة لم يتم اقتراحها بالفعل. يوصى بشدة باستخدام نموذج الميزة ولكنه ليس إلزاميًا.
#### تنفيذ طلب ميزة
إذا وجدت ميزة تشعر أنك قد تكون قادرًا على تنفيذها، لا تخجل. افتح PR مع الإصلاح وتأكد من ذكر الميزة التي تقوم بتنفيذها.
### ما PRs التي نقبلها؟
- إصلاحات المشاكل.
- تنفيذ الميزات.
- الأخطاء المطبعية أو الكلمات الأفضل والأسهل للاستخدام.
- مساهمات الموقع.
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# "YouTube Dislike Sayını Geri Qaytar" layihəsinə qatqı qaydalarına Xoş Gəlmisiniz
Layihəmizə qatqı etmək üçün vaxt ayırdığınız üçün təşəkkür edirik! Bütün dəyişiklikləriniz uzantının növbəti versiyasına (və ya [internet saytına](https://www.returnyoutubedislike.com/)) əlavə ediləcəkdir.
## Başlamaq
Formatlama üçün xahiş olunur ki, Prettier-i standart parametrlərdə istifadə edin.
#### Tələblər
Layihənin paketlənmiş versiyasını yaratmaq üçün node və npm quraşdırılmalıdır.
Quraşdırma zamanı istifadə olunan versiyalar:
- node: 12.18.4
- npm: 6.14.6
Bu uzantının əsas məntiqini ehtiva edən `bundled-content-script.js` faylını yaratmaq üçün əvvəlcə bütün asılılıqları quraşdırmalısınız.
1. Deponun əsas qovluğuna keçin və aşağıdakı əmri icra edin:
```
npm install
```
2. `manifest.json` içərisində istifadə olunan `bundled-content-script.js` faylını yaratmaq üçün aşağıdakı əmri çalışdırın.
```
npm start // fayl yaradılarkən və saxlanarkən avtomatik yenilənən fayl izləyicisini başlatmaq üçün
// ya da
npm run build // faylları yalnız bir dəfə yaratmaq üçün
```
Təbriklər, artıq inkişaf etməyə hazırsınız!
Chrome uzantılarının inkişafına yeni başlamısınızsa və ya əlavə yardıma ehtiyacınız varsa, lütfən [bu YouTube dərsinə](https://www.youtube.com/watch?v=mdOj6HYE3_0) baxın.
### Issue'lar
#### Yeni bir issue açmaq
Uzantı ilə bağlı hər hansı bir probleminiz varsa, problemin əvvəllər bildirilmədiyindən əmin olmaq üçün axtarış aparın. Əgər daha əvvəl bildirilməyibsə, bir mövzu açın. Problem formunun istifadəsi şiddətlə tövsiyə olunur, lakin məcburi deyil.
#### Bir issue'yu həll etmək
Həll edə biləcəyinizi düşündüyünüz bir problem tapsanız, çəkinmədən çalışın. Problemi həll edən bir PR açın və həll etdiyiniz problemi müəyyənləşdirdiyinizdən əmin olun.
### Xüsusiyyət İstəkləri
#### Yeni bir xüsusiyyət istəyi açmaq
Uzantı haqqında bir ideyanız varsa, yeni bir xüsusiyyət istəyi açmaqdan çəkinməyin, lakin xüsusiyyətin daha əvvəl təklif olunmadığından əmin olmaq üçün axtarış edin. Xüsusiyyət formasının istifadəsi şiddətlə tövsiyə olunur, lakin məcburi deyil.
#### Bir xüsusiyyət istəyini tətbiq etmək
Tətbiq edə biləcəyinizi düşündüyünüz bir xüsusiyyət tapsanız, çəkinmədən çalışın. Xüsusiyyəti tətbiq edən bir PR açın və əlavə etdiyiniz xüsusiyyəti qeyd etdiyinizdən əmin olun.
### Hansı PR növlərini qəbul edirik?
- Problem həlləri.
- Xüsusiyyət tətbiqləri.
- Yazı səhvləri və ya daha aydın ifadələr.
- Sayt qatqıları.
```
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Добре дошли в ръководството за сътрудничество на Return YouTube Dislikes
Благодарим ви, че отделяте време за принос към нашия проект! Всички вашите промени ще се отразят в следващата версия на разширението (или на [уебсайта](https://www.returnyoutubedislike.com/)).
## Започване
Моля, използвайте Prettier с настройките по подразбиране за форматиране.
#### Изисквания
Трябва да имате инсталирани node и npm, за да създадете обединената версия на изходния код.
Използвани версии при настройване:
- node: 12.18.4
- npm: 6.14.6
За да създадете `bundled-content-script.js`, който съдържа повечето от бизнес логиката на това разширение, трябва първо да инсталирате всички зависимости.
1. Отидете в коренната папка на хранилището и изпълнете:
```
npm install
```
2. Изпълнете следната команда, за да създадете `bundled-content-script.js`, който се използва в `manifest.json`
```
npm start // за създаване на файл(ове) за сборка и стартиране на файлов наблюдател, който рестартира автоматично след запазване
// или
npm run build // за създаване на файл(ове) за сборка веднъж
```
Поздравления, вече сте готови за разработка!
Ако сте нов в разработката на разширения за Chrome или ви трябва допълнителна помощ, моля вижте [този урок на YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Проблеми
#### Отваряне на нов проблем
Ако имате проблеми с разширението, моля, направете търсене, за да се уверите, че проблемът вече не е сигнализиран. Ако не е, отворете проблема (issue), използването на формуляра за проблем е настоятелно препоръчително, но не задължително.
#### Решаване на проблем
Ако сте открили проблем, който чувствате, че можете да решите, не бъдете срамежливи. Направете PR с решението и се уверете, че споменавате проблема, който решавате.
### Заявки за функционалности
#### Отваряне на нова заявка за функционалност
Ако имате идея за разширението, не се колебайте да отворите заявка за функционалност, но моля, направете търсене, за да се уверите, че функционалността вече не е предложена. Използването на формуляра за функционалности е настоятелно препоръчително, но не задължително.
#### Изпълнение на заявка за функционалност
Ако сте открили функционалност, която чувствате, че можете да изпълните, не бъдете срамежливи. Отворете PR с решението и се уверете, че споменавате функционалността, която изпълнявате.
### Какви PR-ове приемаме?
- Коригиране на проблеми.
- Изпълнение на функционалности.
- Правописни грешки или по-добри и по-лесни думи за употреба.
- Приноси към уебсайта.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# 欢迎来到 Return YouTube Dislikes 贡献指南
感谢您花时间为我们的项目做出贡献!您的所有更改都将反映在扩展的下一个版本中(或 [网站](https://www.returnyoutubedislike.com/))。
## 入门
请使用 Prettier 的默认设置进行格式化。
#### 先决条件
您需要安装 node 和 npm 才能创建源代码的捆绑版本。
设置时使用的版本:
- node12.18.4
- npm6.14.6
要创建包含此扩展的大部分业务逻辑的 `bundled-content-script.js`,您必须先安装所有依赖项。
1. 转到 repo 的根目录并运行:
```
npm install
```
2. 运行以下命令创建 `manifest.json` 中使用的 `bundled-content-script.js`
```
npm start // 创建构建文件并启动在保存时热重载的文件观察器
// 或
npm run build // 创建构建文件一次
```
恭喜,您现在可以开始开发了!
如果您是 Chrome 扩展程序开发新手,或者需要额外帮助,请参阅[此 YouTube 教程](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### 问题
#### 打开新问题
如果您对扩展程序有任何问题,请搜索以确保该问题尚未被报告。如果没有,请打开一个问题,强烈建议使用问题表单,但这不是强制性的。
#### 解决问题
如果您发现一个您认为可以解决的问题,请随时提出来,不必客气。打开带有修复程序的 PR,并确保提及您正在修复的问题。
### 功能请求
#### 打开新的功能请求
如果您对扩展程序有想法,请随时打开功能请求,但请先搜索以确保该功能尚未被建议。强烈建议使用功能表单,但这不是强制性的
#### 实现功能请求
如果您发现您认为可以实现的功能,请随时提出来,不必客气。打开带有修复的 PR,并确保提及您正在实现的功能。
### 我们接受哪些 PR
- 问题修复。
- 功能实现。
- 拼写错误或更好、更易用的词语。
- 网站贡献。
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Velkommen til Return YouTube Dislike bidragsvejledning
Tak fordi du bruger din tid på at bidrage til vores projekt! Alle dine ændringer vil blive afspejlet i den næste version af udvidelsen (eller på [websitet](https://www.returnyoutubedislike.com/)).
## Kom godt i gang
Brug venligst Prettier med standardindstillinger til formatering.
#### Forudsætninger
Du skal have Node og npm installeret for at kunne opbygge den bundtede version af koden.
Versioner brugt ved opsætning:
- node: 12.18.4
- npm: 6.14.6
For at oprette `bundled-content-script.js`, som indeholder størstedelen af udvidelsens forretningslogik, skal du først installere alle afhængigheder.
1. Gå til rodmappen af repoet og kør:
```
npm install
```
2. Kør følgende kommando for at oprette `bundled-content-script.js`, som bruges i `manifest.json`
```
npm start // opret build-fil(er) og start en watcher, der genindlæser ved gem
// eller
npm run build // opret build-fil(er) én gang
```
Tillykke, du er nu klar til at udvikle!
Hvis du er ny i udvikling af Chromeudvidelser eller har brug for ekstra hjælp, se [denne YouTubevejledning](https://www.youtube.com/watch?v=mdOj6HYE3_0).
### Issues
#### Opret en ny issue
Hvis du har problemer med udvidelsen, så søg først for at sikre, at problemet ikke allerede er rapporteret. Hvis ikke, opret en issue. Det anbefales stærkt at bruge issueskabelonen, men det er ikke påkrævet.
#### Løs en issue
Hvis du har fundet et problem, du mener, du kan løse, så tøv ikke. Opret en PR med rettelsen, og husk at nævne den issue, du løser.
### Feature‑ønsker
#### Opret et nyt feature‑ønske
Hvis du har en idé til udvidelsen, er du velkommen til at oprette et feature‑ønske, men søg venligst først for at sikre, at det ikke allerede er foreslået. Brug af featureskabelonen anbefales, men er ikke påkrævet.
#### Implementér et feature‑ønske
Hvis du har fundet et feature, som du kan implementere, så tøv ikke. Opret en PR med implementeringen, og husk at nævne det feature, du implementerer.
### Hvilke PRer accepterer vi?
- Fejlrettelser.
- Implementering af funktioner.
- Ret stavefejl eller brug bedre og enklere formuleringer.
- Bidrag til websitet.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Willkommen beim Leitfaden für Beitragende von Return YouTube Dislikes
Vielen Dank, dass du deine Zeit investierst, um zu unserem Projekt beizutragen! Alle deine Änderungen werden in der nächsten Version der Erweiterung (oder der [Website](https://www.returnyoutubedislike.com/)) reflektiert.
## Erste Schritte
Bitte verwende Prettier mit den Standardeinstellungen für die Formatierung.
#### Voraussetzungen
Du musst Node und npm installiert haben, um die gebündelte Version der Quelle zu erstellen.
Verwendete Versionen beim Einrichten:
- Node: 12.18.4
- npm: 6.14.6
Um die `bundled-content-script.js` zu erstellen, die den Großteil der Geschäftslogik dieser Erweiterung enthält, musst du zuerst alle Abhängigkeiten installieren.
1. Gehe zum Root-Verzeichnis des Repos und führe aus:
```
npm install
```
2. Führe den folgenden Befehl aus, um `bundled-content-script.js` zu erstellen, das in `manifest.json` verwendet wird:
```
npm start // um die Build-Datei(en) zu erstellen und einen Datei-Watcher zu starten, der bei jedem Speichern neu lädt
// oder
npm run build // um die Build-Datei(en) einmal zu erstellen
```
Herzlichen Glückwunsch, du bist jetzt bereit zum Entwickeln!
Wenn du neu in der Entwicklung von Chrome-Erweiterungen bist oder zusätzliche Hilfe benötigst, sieh dir bitte [dieses YouTube-Tutorial](https://www.youtube.com/watch?v=mdOj6HYE3_0) an.
### Probleme
#### Öffnen eines neuen Problems
Wenn du Probleme mit der Erweiterung hast, suche bitte, um sicherzustellen, dass das Problem noch nicht gemeldet wurde. Wenn nicht, öffne ein Problem. Die Verwendung des Problemformulars wird dringend empfohlen, ist aber nicht zwingend erforderlich.
#### Lösung eines Problems
Wenn du ein Problem gefunden hast, das du lösen könntest, sei nicht schüchtern. Öffne einen PR mit der Lösung und vergewissere dich, dass du das behobene Problem erwähnst.
### Feature-Anfrage
#### Öffnen einer neuen Feature-Anfrage
Wenn du eine Idee für die Erweiterung hast, kannst du gerne eine Feature-Anfrage öffnen, aber suche bitte zuerst, um sicherzustellen, dass das Feature noch nicht vorgeschlagen wurde. Die Verwendung des Feature-Formulars wird dringend empfohlen, ist aber nicht zwingend erforderlich.
#### Implementierung einer Feature-Anfrage
Wenn du ein Feature gefunden hast, das du implementieren könntest, sei nicht schüchtern. Öffne einen PR mit der Implementierung und vergewissere dich, dass du das implementierte Feature erwähnst.
### Welche PRs akzeptieren wir?
- Fehlerbehebungen.
- Implementierung von Funktionen.
- Rechtschreibfehler oder bessere und einfachere Wörter zur Verwendung.
- Beiträge zur Website.
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Bienvenido(a) a la guía de contribución de Return YouTube Dislikes
¡Gracias por invertir tiempo en contribuir al proyecto! Todos sus cambios serán mostrados en la siguiente versión de la extensión (o en la [pagina web](https://www.returnyoutubedislike.com/))
## Empecemos
Por favor, utiliza Prettier con la configuración predeterminada para el formateo
#### Requisitos previos
Necesita tener instalados node y npm para crear la versión empaquetada del código fuente
Versiones utilizadas durante la configuración:
- node: 12.18.4
- npm: 6.14.6
Para crear el archivo `bundled-content-script.js` que contiene la mayor parte de la lógica de negocio de esta extensión, primero debe instalar todas las dependencias
1. Ve a la raíz del repositorio y ejecute:
```
npm install
```
2. Ejecuta el siguiente comando para crear `bundled-content-script.js`, el cual se utiliza en `manifest.json`
```
npm start // para crear el/los archivo(s) de compilación y comenzar un observador de archivos que se recargue automáticamente al guardar cambios
// o
npm run build // para crear el/los archivo(s) de compilación una vez
```
¡Felicitaciones! ¡Ahora estás listo(a) para desarrollar!
Si eres nuevo/a en el desarrollo de extensiones de Chrome o necesitas ayuda adicional, por favor consulta [este tutorial en YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Problemas
#### Abrir un nuevo problema
Si tiene algún problema con la extensión, por favor verifique si el problema ya ha sido reportado. Si no lo ha sido, abre un problema. Se recomienda encarecidamente utilizar el formulario de problema, pero no es obligatorio.
#### Resolver un problema
Si encontró un problema que crea que pueda resolver, no dude en hacerlo. Abra una solicitud de extracción (PR) con la solución y asegúrese de mencionar el problema que está solucionando
### Solicitud de función
#### Abrir una nueva solicitud de función
If you have an idea for the extension, feel free to open a feature request, but please search it before to make sure the feature isn't already suggested. Using the feature form is highly recommended but not mandatory
#### Implementando una solicitud de funcionalidad
Si encontró una función que pueda implementar, no dude en hacerlo. Abra una solicitud de extracción (PR) con la solución y asegúrese de mencionar la función que está implementando
### ¿Qué PRs aceptamos?
- Corrección de problemas.
- Implementación de funciones.
- Corrección de errores tipográficos o uso de palabras mejores y más sencillas.
- Contribuciones al sitio web.
+2 -1
View File
@@ -1,4 +1,5 @@
Lisez ceci dans d'autres langues : [English](CONTRIBUTING.md)
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Bienvenue dans le guide de contribution à Return YouTube Dislikes
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Welcome To Return YouTube Dislikes contributing guide
Σας ευχαριστούμε που αφιερώσατε χρόνο για να συνεισφέρετε στο έργο μας! Όλες οι αλλαγές σας θα εμφανιστούν στην επόμενη έκδοση της επέκτασης (ή στην [ιστοσελίδα](https://www.returnyoutubedislike.com/)).
## Ξεκινώντας
Παρακαλώ χρησιμοποιήστε το Prettier με τις προκαθορισμένες ρυθμίσεις για τη μορφοποίηση.
#### Προαπαιτούμενα
Πρέπει να έχετε το node και το npm εγκαταστημένο για να δημιουργήσετε την ομαδοποιημένη έκδοση της πηγής.
Εκδόσεις για την εγκατάσταση:
- node: 12.18.4
- npm: 6.14.6
Για να δημιουργήσετε το `bundled-content-script.js` που περιέχει το περισσότερο business logic αυτής της επέκτασης πρέπει να εγκαταστήσετε όλα τα dependencies πρώτα.
1. Πηγαίντε στη ρίζα (root) του repo και εκτελέστε:
```
npm install
```
2. Εκτελέστε την ακόλουθη εντολή για να δημιουργήσετε το `bundled-content-script.js` το οποίο χρησιμοποιείται στο `manifest.json`
```
npm start // για να δημιουργήσετε τα build file(s) και να ξεκινήσετε ένα πρόγραμμα παρακολούθυησης αρχείων που τα φορτώνει ξανά κατά την αποθήκευση
// ή
npm run build // για να δημιουργήσετε τα build file(s) μία φορά
```
Συγχαρητήρια, τώρα είστε έτοιμος να δημιουργήσετε!
Εάν είστε νέος στη δημιουργία επεκτάσεων στο Chrome, ή χρειάζεστε επιπλέον βοήθεια, παρακαλώ δείτε [αυτόν τον οδηγό στο YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Ζητήματα
#### Δημιουργώντας ένα νέο ζήτημα
Εάν αντιμετωπίζετε κάποιο πρόβλημα με την επέκταση, παρακαλώ κάντε μια αναζήτηση για να σιγουρευτείτε ότι το ζήτημα δεν έχει αναφερθεί ήδη. Αν δεν είναι, δημιουργήστε ένα νέο ζήτημα χρησιμοποιώντας την φόρμα ζητημάτων η οποία συστήνεται αλλά δεν είναι απαραίτητη.
#### Λύνοντας ένα ζήτημα
Εάν βρήκατε ένα ζήτημα που πιστεύετε ότι ίσως μπορέσετε να λύσετε, μην ντρέπεστε. Ανοίξτε ένα PR με την επιδιόρθωση και φροντίστε να αναφέρετε το πρόβλημα που επιδιορθώνετε.
### Αίτηση για νέα λειτουργίας
#### Άνοιγμα αιτήματος νέας λειτουργίας
Εάν έχετε μια ιδέα για την επέκταση, μη διστάσετε να ανοίξετε ένα αίτημα λειτουργίας, αλλά ψάξτε το προηγουμένως για να βεβαιωθείτε ότι η λειτουργία δεν έχει ήδη προταθεί. Η χρήση της φόρμας για νέες λειτουργίες συστήνεται αλλά δεν είναι απαραίτητη.
#### Υλοποιώντας ένα αίτημα νέας λειτουργίας
Αν βρήκατε μια δυνατότητα που πιστεύετε ότι μπορεί να μπορέσετε να εφαρμόσετε, μην ντρέπεστε. Ανοίξτε ένα PR με την επιδιόρθωση και φροντίστε να αναφέρετε τη δυνατότητα που εφαρμόζετε.
### Τι PR δεχόμαστε;
- Διορθώσεις ζητημάτων.
- Υλοποίηση νέων λειτουργιών.
- Τυπογραφικά λάθη ή καλύτερες και πιο εύκολες λέξεις στη χρήση.
- Συνεισφορές στην ιστοσελίδα.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Üdvözlünk a Return YouTube Dislike közreműködői útmutatójában
Köszönjük, hogy időt szánsz a projekthez való hozzájárulásra! Minden módosításod meg fog jelenni a bővítmény következő verziójában (vagy a [weboldalon](https://www.returnyoutubedislike.com/)).
## Kezdő lépések
Kérjük, a formázáshoz használd a Prettier eszközt alapértelmezett beállításokkal.
#### Előkövetelmények
A forrás kimenetének elkészítéséhez szükséged lesz a Node és az npm telepítésére.
Az előkészítéskor használt verziók:
- node: 12.18.4
- npm: 6.14.6
A `bundled-content-script.js` létrehozásához (ami a bővítmény üzleti logikájának nagy részét tartalmazza) először telepíteni kell az összes függőséget.
1. Lépj a repó gyökerébe és futtasd:
```
npm install
```
2. Futtasd az alábbi parancsot a `bundled-content-script.js` elkészítéséhez, amelyet a `manifest.json` használ:
```
npm start // a build fájl(ok) létrehozása és egy watcher indítása, amely mentéskor újratölt
// vagy
npm run build // a build fájl(ok) egyszeri létrehozása
```
Gratulálunk, készen állsz a fejlesztésre!
Ha új vagy a Chromebővítmények fejlesztésében, vagy extra segítségre van szükséged, nézd meg [ezt a YouTubeoktatóanyagot](https://www.youtube.com/watch?v=mdOj6HYE3_0).
### Hibák és problémák
#### Új issue megnyitása
Ha problémád van a bővítménnyel, először keress rá, hogy nem jelentettéke már. Ha nem, nyiss egy issuet. Erősen ajánlott az issue űrlap használata, de nem kötelező.
#### Egy issue megoldása
Ha találtál egy problémát, amit meg tudsz oldani, ne habozz. Nyiss egy PRt a javítással, és mindenképp említsd meg, melyik issuet javítod.
### Funkciókérések
#### Új funkciókérés megnyitása
Ha van egy ötleted a bővítményhez, nyugodtan nyiss funkciókérést, de előtte keresd meg, hogy nem javasoltáke már. A funkció űrlap használata ajánlott, de nem kötelező.
#### Funkciókérés megvalósítása
Ha találtál egy funkciót, amit meg tudsz valósítani, ne habozz. Nyiss egy PRt a megvalósítással, és említsd meg a funkciót, amit implementálsz.
### Milyen PRokat fogadunk el?
- Hibajavítások.
- Funkciók implementálása.
- Elírások javítása, jobb és egyszerűbb megfogalmazások.
- Közreműködések a weboldalon.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Selamat datang di petunjuk kontribusi Return Youtube Dislikes
Terima kasih telah menggunakan waktumu untuk berkontribusi pada proyek kami! Semua perubahan akan diaplikasikan pada versi extension selanjutnya (atau [website](https://www.returnyoutubedislike.com/)).
## Mula-mula
Tolong gunakan Prettier dengan setting bawaan untuk melakukan formatting.
#### Prerequisites
Kamu harus menginstall node dan npm untuk membuat versi bundled dari source code.
Versi yang digunakan:
- node: 12.18.4
- npm: 6.14.6
Untuk membuat `bundled-content-script.js` yang memiliki semua logic dari extension ini, kamu harus menginstall semua dependency terlebih dahulu.
1. Masuk ke root dari repo dan jalankan:
```
npm install
```
2. Jalankan command berikut untuk membuat `bundled-content-script.js` yang akan digunakan di `manifest.json`
```
npm start // untuk membuat build file dan menjalankan file watcher yang akan melakukan hot-reloads ketika menyimpan.
// atau
npm run build // untuk membuat build file satu kali.
```
Selamat, kamu sekarang sudah siap untuk melakukan develop!
Jika kamu baru dalam melakukan develop extension Chrome, atau membutuhkan bantuan tambahan, tolong kunjungi [tutorial Youtube ini](https://www.youtube.com/watch?v=mdOj6HYE3_0).
### Issues
#### Membuat issue baru
Jika kamu menemuakan issue mengenai extension, tolong cari terlebih dahulu untuk memastikan issue tersebuat telah di laporkan. Jika belum, buat issue baru, direkomendasikan menggunakan form issue tapi tidak harus.
#### Menyelesaikan issue
Jika kamu menemukan issue yang dapat kamu selesaikan, jangan malu. Buat PR dan pastikan untuk mention issue yang sedang diperbaiki.
### Rqeust Fitur
#### Membuat request fitur
Jika kamu memiliki ide mengenai extension, silakan untuk membuat request fitur, tapi tolong cari terlebih dahulu untuk memastikan fitur tersebut belum direquest oleh orang lain. Direkomendasikan menggunakan form request fitur tapi tidak harus.
#### Implementasi request fitur
Jika kamu menemukan request fitur yang dapat kamu kerjakan, jangan malu. Buat PR dan pastikan untuk mention request fitur yang sedang dikerjakan.
### PR apa saja yang kami terima?
- Memperbaiki issue.
- Implementasi fitur.
- Typo atau improvisasi kata dan kalimat.
- Kontribusi website.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Return YouTube Dislike へのコントリビュートガイドへようこそ
本プロジェクトへのご協力に時間を割いていただきありがとうございます! 皆さまの変更は、拡張機能の次のリリース(または[ウェブサイト](https://www.returnyoutubedislike.com/))に反映されます。
## はじめに
フォーマットには Prettier をデフォルト設定のまま使用してください。
#### 前提条件
ソースのバンドル版を作成するには、Node と npm をインストールする必要があります。
セットアップ時のバージョン:
- node: 12.18.4
- npm: 6.14.6
この拡張機能の大部分のロジックを含む `bundled-content-script.js` を作成するには、まず依存関係をインストールします。
1. リポジトリのルートに移動して、以下を実行します:
```
npm install
```
2. `manifest.json` で使用される `bundled-content-script.js` を作成するには、次のコマンドを実行します。
```
npm start // ビルドファイルを作成し、保存時に自動リロードするウォッチャーを開始
// または
npm run build // 一度だけビルドファイルを作成
```
おめでとうございます。これで開発を始める準備ができました!
Chrome 拡張の開発が初めて、または追加のヘルプが必要な場合は、[こちらの YouTube チュートリアル](https://www.youtube.com/watch?v=mdOj6HYE3_0)をご覧ください。
### Issue について
#### 新しい Issue を作成する
拡張機能に問題がある場合は、既に報告されていないか検索してください。未報告であれば Issue を作成してください。Issue フォームの使用は強く推奨しますが必須ではありません。
#### Issue を解決する
解決できそうな Issue を見つけた場合は、遠慮なく修正の PR を作成し、修正した Issue を必ず明記してください。
### 機能リクエスト
#### 新しい機能リクエストを作成する
拡張機能に関するアイデアがある場合は、自由に機能リクエストを作成してください。ただし、既に提案されていないか事前に検索してください。機能フォームの使用は強く推奨しますが必須ではありません。
#### 機能リクエストを実装する
実装できそうな機能を見つけた場合は、遠慮なく実装の PR を作成し、実装する機能を必ず明記してください。
### 受け付ける PR
- 不具合修正
- 機能の実装
- 誤字修正や、より良く簡潔な表現への改善
- ウェブサイトへの貢献
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Return YouTube Dislike 기여 가이드에 오신 것을 환영합니다
프로젝트에 기여해 주셔서 감사합니다! 여러분의 변경 사항은 확장 프로그램의 다음 버전(또는 [웹사이트](https://www.returnyoutubedislike.com/))에 반영됩니다.
## 시작하기
코드 포매팅에는 기본 설정의 Prettier를 사용해 주세요.
#### 사전 준비물
소스의 번들 버전을 만들려면 Node와 npm이 설치되어 있어야 합니다.
설정 시 사용한 버전:
- node: 12.18.4
- npm: 6.14.6
이 확장 프로그램의 대부분의 비즈니스 로직을 담은 `bundled-content-script.js`를 생성하려면 먼저 모든 의존성을 설치해야 합니다.
1. 저장소 루트로 이동하여 다음을 실행하세요:
```
npm install
```
2. `manifest.json`에서 사용하는 `bundled-content-script.js`를 생성하려면 다음 명령을 실행하세요:
```
npm start // 빌드 파일을 생성하고 저장 시 자동 새로고침하는 감시자 시작
// 또는
npm run build // 빌드 파일을 한 번만 생성
```
축하합니다. 이제 개발할 준비가 되었습니다!
Chrome 확장 프로그램 개발이 처음이거나 추가 도움이 필요하다면 [이 YouTube 튜토리얼](https://www.youtube.com/watch?v=mdOj6HYE3_0)을 참고하세요.
### 이슈
#### 새 이슈 만들기
확장 프로그램에 문제가 있다면, 먼저 동일한 이슈가 이미 보고되지 않았는지 검색해 주세요. 없다면 이슈를 생성해 주세요. 이슈 템플릿 사용을 권장하지만 필수는 아닙니다.
#### 이슈 해결하기
해결할 수 있을 것 같은 이슈를 찾았다면 주저하지 마세요. 수정 사항으로 PR을 열고, 해결 중인 이슈를 꼭 언급해 주세요.
### 기능 요청
#### 새 기능 요청 만들기
확장 프로그램에 대한 아이디어가 있다면 기능 요청을 자유롭게 열어 주세요. 다만, 이미 제안된 내용이 아닌지 먼저 검색해 주세요. 기능 템플릿 사용을 권장하지만 필수는 아닙니다.
#### 기능 요청 구현하기
구현할 수 있을 것 같은 기능을 찾았다면 주저하지 마세요. 구현 내용으로 PR을 열고, 구현하는 기능을 꼭 언급해 주세요.
### 어떤 PR을 수락하나요?
- 버그 수정
- 기능 구현
- 오타 수정 또는 더 좋고 간결한 표현으로 개선
- 웹사이트 관련 기여
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Welkom bij de YouTube Dislikes bijdragengids
Bedankt voor het investeren van uw tijd in het bijdragen aan ons project! Al uw wijzigingen worden weergegeven in de volgende versie van de extensie (of de [website](https://www.returnyoutubedislike.com/)).
## Aan de slag
Gebruik Prettier met standaardinstellingen voor opmaak.
#### Vereisten
U moet node en npm hebben geïnstalleerd om de gebundelde versie van de broncode te maken.
Versies gebruikt bij het instellen:
- node: 12.18.4
- npm: 6.14.6
Om de `bundled-content-script.js` te maken die de meeste bedrijfslogica van deze extensie bevat, moet u eerst alle afhankelijkheden installeren.
1. Ga naar de hoofdmap van de repo en voer het volgende uit:
```
npm install
```
2. Voer de volgende opdracht uit om `bundled-content-script.js` aan te maken dat wordt gebruikt in `manifest.json`
```
npm start // om het (de) buildbestand(en) te maken en een bestandswachter te starten die bij het opslaan opnieuw wordt geladen
// of
npm run build // om het (de) buildbestand(en) eenmaal te maken
```
Gefeliciteerd, je bent nu klaar om te ontwikkelen!
Als je nieuw bent in het ontwikkelen van Chrome-extensies of extra hulp nodig hebt, bekijk dan [deze YouTube-tutorial](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Problemen
#### Een nieuw probleem openen
Als je problemen hebt met de extensie, zoek dan eerst of het probleem nog niet is gemeld. Als dit niet het geval is, open dan een probleem, het gebruik van het probleemformulier wordt sterk aanbevolen, maar is niet verplicht.
#### Een probleem oplossen
Als je een probleem hebt gevonden waarvan je denkt dat je het zou kunnen oplossen, wees dan niet verlegen. Open een PR met de oplossing en vermeld het probleem dat u aan het oplossen bent.
### Functieverzoek:
#### Een nieuw functieverzoek openen
Als je een idee hebt voor de extensie, kun je een functieverzoek openen, maar zoek het eerst op om er zeker van te zijn dat de functie niet al is voorgesteld. Het gebruik van het functieformulier wordt sterk aanbevolen, maar is niet verplicht.
#### Een functieverzoek implementeren
Als je een functie hebt gevonden waarvan je denkt dat je die zou kunnen implementeren, wees dan niet verlegen. Open een PR met de oplossing en zorg ervoor dat u de functie vermeldt die u implementeert.
### Welke PR's accepteren we?
- Probleemoplossingen.
- Implementatie van functies.
- Typefouten of betere en gemakkelijkere woorden om te gebruiken.
- Website bijdragen.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Witamy w przewodniku współtworzenia Return YouTube Dislike
Dziękujemy za zainwestowanie czasu w rozwój naszego projektu! Wszystkie Twoje zmiany znajdą się w następnej wersji rozszerzenia ([bądź strony](https://www.returnyoutubedislike.com/)).
## Początek
Prosimy używać Prettier z domyślnymi ustawieniami do formatowania.
#### Wymagania wstępne
Musisz mieć zainstalowane node i npm, aby utworzyć dołączoną wersję źródła.
Wersje używane przy ustawianiu:
- node: 12.18.4
- npm: 6.14.6
Aby utworzyć `bundled-content-script.js`, które zawiera większość logiki tego rozszerzenia, musisz najpierw zainstalować wszystkie zależności.
1. Przejdź do korzenia tego repo i uruchom:
```
npm install
```
2. Użyj polecenia poniżej aby stworzyć `bundled-content-script.js`, które jest używane w `manifest.json`
```
npm start // aby utworzyć plik(i) build-u i uruchomić obserwatora pliku, który przeładowuje po zapisie
// lub
npm run build // aby jednorazowo utworzyć plik(i) build-u
```
Gratulacje, jesteś gotów pisać!
Jeśli jesteś nowy w pisaniu rozszerzeń do Chrome, lub potrzebujesz dodatkowej pomocy, obejrzyj [ten poradnik na YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Problemy
#### Otwieranie nowego problemu
Jeśli masz jakiekolwiek problemy z rozszerzeniem, najpierw wyszukaj go aby upewnić się, że dany problem nie został już zgłoszony. Jeżeli nie, otwórz problem. Używanie formularza problemu jest zalecane, ale nie jest konieczne.
#### Rozwiązywanie problemu
Jeżeli znalazłeś problem, który myślisz, że jesteś w stanie rozwiązać, nie wstydź się. Otwórz PR z fix-em i opisz problem, który naprawiasz.
### Prośba o funkcjonalność
#### Otwieranie nowej prośby o funkcjonalność
Jeżeli masz pomysł na rozszerzenie, śmiało otwórz nowe żądanie o funkcjonalność, ale prosimy o wyszukanie swojego pomysłu, aby upewnić się, że nie został on już zasugerowany. Używanie formularza jest zalecane, ale nie jest konieczne.
#### Implementacja prośby o funkcjonalność
Jeżeli znalazłeś pomysł na funkcjonalność, którą myślisz, że jesteś w stanie zaimplementować, nie wstydź się. Otwórz PR z fix-em, i opisz funkcjonalność, którą implementujesz.
### Jakie PR-y przyjmujemy?
- Naprawy problemów.
- Implementacja funkcjonalności.
- Literówki lub lepsze i łatwiejsze w zrozumieniu słowa.
- Współtworzenie strony.
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Bem-vindo ao guia de contribuição do Return YouTube Dislikes
Obrigado por investir seu tempo contribuindo para o nosso projeto! Todas as mudanças serão refletidas na próxima versão da extensão (ou do [website](https://www.returnyoutubedislike.com/)).
## Começando
Por favor use o Prettier com as configurações padrão de formatação.
#### Pré requisitos
Você precisa ter o node e o npm instalados para criar o pacote a partir do código-fonte.
Versões usadas na configuração:
- node: 12.18.4
- npm: 6.14.6
Para criar o `bundled-content-script.js` que contém muita da lógica de negócio dessa extensão você precisará instalar todas as dependências primeiro.
1. Vá para a raiz do repositório e execute:
```
npm install
```
2. Execute o comando a seguir para criar o `bundled-content-script.js` que será usado no `manifest.json`
```
npm start // Para criar os arquivos de build e iniciar um file watcher que recarrega o conteúdo automaticamente ao salvar
// ou
npm run build // Para criar os arquivos de build apenas uma vez
```
Parabéns, agora você está pronto para desenvolver!
Se você é novo no desenvolvimento de extensões para o Chrome, ou precisa de ajuda extra, por favor dê uma olhada [nesse tutorial do YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Problemas(Issues)
#### Abrindo uma nova issue
Se você tiver qualquer problema com a extensão, por favor pesquise nas issues do nosso repositório para ter certeza de que o problema já não foi reportado. Se não foi, abra uma issue, usar o formulário de issues é altamente recomendado mas não é obrigatório.
#### Resolvendo issues
Se você encontrou uma issue que você sente que é capaz de corrigir, não tenha vergonha. Abra uma PR com a correção e certifique-se de mencionar a issue que você está corrigindo.
### Sugerir um nova funcionalidade(Feature request)
#### Abrindo uma feature request
Se você tem uma ideia de funcionalidade para a extensão, sinta-se livre para abrir uma feature request no nosso repositório, mas por favor certifique-se de que a funcionalidade já não foi sugerida. Usar o formulário de features é altamente recomendado mas não é obrigatório.
#### Implementando uma feature request
Se você encontrou uma feature request que você sente que é capaz de implementar, não tenha vergonha. Abra uma PR com a implementação e certifique-se de mencionar a feature request que você está implementando.
### Quais PRs nós aceitamos?
- Correção de problemas.
- Implementação de funcionalidades.
- Erros de digitação e palavras melhores ou mais fáceis de entender.
- Contribuições para o Website.
+2 -1
View File
@@ -1,4 +1,5 @@
Прочитать на других языках: [English](CONTRIBUTING.md)
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Добро пожаловать в руководство по внесению вклада Return YouTube Dislikes
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Välkommen till Return YouTube Dislike bidragsguide
Tack för att du lägger tid på att bidra till vårt projekt! Alla dina ändringar kommer att synas i nästa version av tillägget (eller på [webbplatsen](https://www.returnyoutubedislike.com/)).
## Kom igång
Använd gärna Prettier med standardinställningar för formatering.
#### Förutsättningar
Du behöver ha Node och npm installerat för att skapa den bundlade versionen av koden.
Versioner som användes vid uppsättning:
- node: 12.18.4
- npm: 6.14.6
För att skapa `bundled-content-script.js`, som innehåller större delen av tilläggets logik, behöver du först installera alla beroenden.
1. Gå till repots roten och kör:
```
npm install
```
2. Kör följande kommando för att skapa `bundled-content-script.js`, som används i `manifest.json`:
```
npm start // skapar buildfil(er) och startar en filbevakare som laddar om vid spara
// eller
npm run build // skapar buildfil(er) en gång
```
Grattis! Du är nu redo att utveckla.
Om du är ny på att utveckla Chrometillägg, eller behöver extra hjälp, se [den här YouTubeguiden](https://www.youtube.com/watch?v=mdOj6HYE3_0).
### Ärenden (Issues)
#### Skapa ett nytt ärende
Om du har problem med tillägget, sök först för att säkerställa att ärendet inte redan är rapporterat. Om inte, skapa ett ärende. Det rekommenderas starkt att använda ärendeformuläret, men det är inte obligatoriskt.
#### Lösa ett ärende
Om du hittat ett ärende du tror att du kan lösa, tveka inte. Öppna en PR med lösningen och nämn vilket ärende du åtgärdar.
### Funktionsförslag
#### Skapa ett nytt funktionsförslag
Har du en idé för tillägget? Skapa gärna ett funktionsförslag, men sök först för att se till att förslaget inte redan finns. Att använda funktionsformuläret rekommenderas men är inte obligatoriskt.
#### Implementera ett funktionsförslag
Om du hittat en funktion du kan implementera, tveka inte. Öppna en PR med implementationen och nämn vilken funktion du implementerar.
### Vilka PR:er accepterar vi?
- Buggfixar.
- Implementering av funktioner.
- Rättning av stavfel eller förbättrade, enklare formuleringar.
- Bidrag till webbplatsen.
+67
View File
@@ -0,0 +1,67 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# "YouTube Dislike Sayısını Geri Getir"in katkı kılavuzuna Hoş Geldiniz
Projemize katkıda bulunmak için zaman ayırdığınız için teşekkür ederiz! Tüm değişiklikleriniz, uzantının bir sonraki sürümüne (veya [internet sitesi](https://www.returnyoutubedislike.com/)ne) yansıtılacaktır.
## Başlarken
Lütfen formatlama işlemi için, Prettier'i varsayılan ayarlardayken kullanın.
#### Ön Şartlar
Kaynağın paketlenmiş sürümünü oluşturmak için node ve npm'nin kurulu olması gerekir.
Kurulum sırasında kullanılan sürümler:
- node: 12.18.4
- npm: 6.14.6
Bu uzantının iş mantığının çoğunu içeren `bundled-content-script.js`yi oluşturmak için, önce tüm bağımlılıkları yüklemeniz gerekir.
1. Deponun köküne gidin ve şu komutu çalıştırın:
```
npm install
```
2. `manifest.json` içinde kullanılan `bundled-content-script.js` dosyasını oluşturmak için aşağıdaki komutu çalıştırın.
```
npm start // derleme dosyasının/dosyalarının oluşturulması ve kaydedilmesi sırasında çalışırken yeniden yüklenen bir dosya izleyicisini başlatmak için
// ya da
npm run build // derleme dosyasını/dosyalarını bir kez oluşturmak için
```
Tebrikler, artık geliştirmeye hazırsınız!
Chrome uzantıları geliştirme konusunda yeniyseniz veya fazladan yardıma ihtiyacınız olursa lütfen [bu YouTube öğreticisi](https://www.youtube.com/watch?v=mdOj6HYE3_0)ne bakın.
### Issue'lar
#### Yeni bir issue başlatmak
Uzantıyla ilgili herhangi bir sorununuz varsa, sorunun önceden bildirilmediğinden emin olmak için lütfen arama yapın. Eğer daha önce bildirilmediyse, bir konu açın. Sorun formunu kullanmanız şiddetle tavsiye edilir ancak zorunlu değildir.
#### Bir issue'yu çözmek
Çözebileceğinizi düşündüğünüz bir sorun bulduysanız, çekinmeyin. Düzeltmeyi içeren bir PR açın ve düzelttiğiniz sorunu belirttiğinizden emin olun.
### Özellik Talebi
#### Yeni bir özellik talebi açmak
Uzantı hakkında bir fikriniz varsa, bir özellik isteği açmaktan çekinmeyin, ancak özelliğin daha önce önerilmediğinden emin olmak için lütfen önce arama yapın. Özellik formunun kullanılması şiddetle tavsiye edilir ancak zorunlu değildir.
#### Bir özellik isteğini uygulamak
Uygulayabileceğinizi düşündüğünüz bir özellik bulduysanız, çekinmeyin. Düzeltmeyi içeren bir PR açın ve uyguladığınız özelliği belirttiğinizden emin olun.
### Hangi tür PR'leri kabul ediyoruz?
- Sorun düzeltmeleri.
- Özellik uygulaması.
- Yazım hataları veya daha anlaşılabilir ve kullanımı daha kolay kelimeler.
- Site katkıları.
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Вітаємо у посібнику внеску в Return YouTube Dislikes
Дякуємо, що вкладаєте свій час у розвиток нашого проєкту! Усі ваші зміни буде відображено в наступній версії розширення (або ж [вебсайту](https://www.returnyoutubedislike.com/)).
## Почнімо працювати
Будь ласка, використовуйте Prettier із налаштуваннями за замовчуванням для форматування коду.
#### Заздалегідь
Вам потрібно встановити node і npm, щоб створити bundled версію джерела.
Версії, що використовувались при налаштуванні:
- node: 12.18.4
- npm: 6.14.6
Для створення `bundled-content-script.js`, який містить більшу частину бізнес-логіки цього розширення, спочатку потрібно встановити всі залежності.
1. Перейдіть в корінь репозиторію та виконайте наступне:
```
npm install
```
2. Виконайте наступну команду, щоб створити `bundled-content-script.js`, який використовується в `manifest.json`
```
npm start // для створення файлу(ів) збірки та запуску спостерігача за файлами, який виконує hot-reload при збереженні
// або ж
npm run build // для створення файлу(ів) збірки один раз
```
Вітаємо, тепер ви готові до розробки!
Якщо ви новачок у розробці розширення Chrome або вам потрібна додаткова допомога, перегляньте [це керівництво на YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0) (англ.)
### Проблеми
#### Відкриття нової проблеми
Якщо у вас виникли проблеми з розширенням, здійсніть пошук і переконайтеся, що про цю проблему ще не повідомляли. Якщо ні, створіть Issue, використання форми проблеми наполегливо рекомендується, але не є обов’язковим.
#### Вирішення проблеми
Якщо ви знайшли проблему, і гадаєте, що можете її вирішити, не соромтеся. Створіть Pull Request на вилучення з виправленням і обов'язково вкажіть проблему, яку ви усуваєте.
### Запит функції
#### Відкриття запиту на нову функцію
Якщо у вас є ідея щодо розширення, не соромтеся створіть Pull Request, але, будь ласка, здійсніть пошук і переконайтеся, що ця функція ще не запропонована. Використання форми функції наполегливо рекомендується, але не є обов’язковим.
#### Реалізація запиту функції
Якщо ви знайшли функцію, і гадаєте, що можете її реалізувати, не соромтеся. Створіть Pull Request із виправленням і обов’язково вкажіть функцію, яку ви впроваджуєте.
### Які Pull Request ми приймаємо?
- Виправлення проблем.
- Впровадження нових функцій.
- Виправлення помилок та спрощення тексту.
- Поліпшення сайту.
+68
View File
@@ -0,0 +1,68 @@
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
# Chào mừng tới Hướng dẫn Đóng góp của Return YouTube Dislike (Trả lại số lượt Không thích trên YouTube) <!-- # Welcome To Return YouTube Dislikes contributing guide -->
Cảm ơn bạn đã dành thời gian để đóng góp cho dự án của chúng tôi! Tất cả các thay đổi của bạn sẽ được hiển thị trong phiên bản tiếp theo của tiện ích mở rộng này (hoặc của [trang mạng](https://www.returnyoutubedislike.com/)).
## Bắt đầu <!-- ## Getting Started -->
Hãy dùng [Prettier](https://prettier.io/) với thiết lập mặc định để định dạng mã.
#### Yêu cầu sơ bộ <!-- ### Prerequisites -->
Bạn cần phải cài **node****npm** để tạo bản đóng gói của mã nguồn.
Các phiên bản được dùng khi cài đặt:
- node: 12.18.4
- npm: 6.14.6
Dể có thể tạo tệp `bundled-content-script.js`, trong đó có chứa hầu hết các lôgic kinh doanh của tiện ích mở rộng này, trước tiên bạn phải cài các đối tượng phụ thuộc.
1. Tới thư mục gốc của kho mã nguồn và chạy lệnh:
```
npm install
```
2. Chạy lệnh dưới đây để tạo `bundled-content-script.js`, sẽ dùng tới trong `manifest.json`
```
npm start // để tạo (các) tệp xây dựng và khởi chạy một trình quan sát tập tin, đảm nhiệm việc tự động tải lại dự án khi có thay đổi được lưu
// hoặc
npm run build // để tạo (các) tệp xây dựng chỉ một lần
```
Chúc mừng! Bạn đã sẵn sàng để phát triển chương trình!
Nếu bạn chưa bao giờ phát triển tiện ích mở rộng cho Chrome hoặc cần sự trợ giúp, hãy xem [hướng dẫn này trên YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
### Vấn đề <!-- ### Issues -->
#### Tạo một vấn đề mới <!-- #### Opening a new issue -->
Nếu bạn có bất kì vấn đề gì với tiện ích mở rộng này, trước tiên hãy đọc qua danh sách các vấn đề đang có. Nếu vấn đề của bạn không có trong danh sách các vấn đề, hãy [tạo một vấn đề](https://github.com/Anarios/return-youtube-dislike/issues/new?assignees=&labels=bug&template=bug.yml&title=%28Bug%29%3A+). Dùng mẫu đơn vấn đề nếu có thể, nhưng không bắt buộc.
#### Giải quyết một vấn đề <!-- #### Solving an issue -->
Nếu bạn cảm thấy có thể giải quyết một vấn đề nào đó, đừng ngần ngại. Hãy tạo một [yêu cầu kéo](https://github.com/Anarios/return-youtube-dislike/pulls) cho sự thay đổi của bạn và nhớ hãy ghi tên lỗi mà bạn giải quyết.
### Yêu cầu Tính năng <!-- ### Feature Request -->
#### Tạo một yêu cầu tính năng mới <!-- #### Opening a new feature request -->
Nếu bạn có một ý tưởng dành cho tiện ích mở rộng này, hãy mạnh dạn [tạo một yêu cầu chức năng](https://github.com/Anarios/return-youtube-dislike/issues/new?assignees=&labels=enhancement&template=feature-request.yml&title=%28Feature+Request%29%3A+), nhưng hãy chắc rằng bạn đã tìm và không thấy yêu cầu tính năng y hệt trong danh sách yêu cầu tính năng. Dùng mẫu đơn yêu cầu tính năng nếu có thể, nhưng không bắt buộc.
#### Thực hiện một yêu cầu tính năng <!-- #### Implementing a feature request -->
Nếu bạn cảm thấy có thể thực hiện một tính năng nào đó, đừng ngần ngại. Hãy tạo một [yêu cầu kéo](https://github.com/Anarios/return-youtube-dislike/pulls) cho sự thay đổi của bạn và nhớ hãy ghi tên tính năng mà bạn thực hiện.
### Những yêu cầu kéo mà chúng tôi chấp nhận? <!-- ### What PRs do we accept? -->
- Giải quyết vấn đề.
- Thực hiện tính năng.
- Sửa lỗi chính tả hoặc đề xuất cách dùng từ tốt hơn.
- Đóng góp cho trang mạng.
+5 -4
View File
@@ -1,4 +1,5 @@
Read this in other languages: [Français](FAQfr.md)
Read this in other languages: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Frequently Asked Questions
@@ -16,7 +17,7 @@ We save all available data to our DB for it to be available after Google shuts d
### **2. Video dislike count doesn't update**
Right now video dislikes are cached, and aren't updated very frequenly. Once every 2-3 days, not more often.
Right now video dislikes are cached, and aren't updated very frequently. Once every 2-3 days, not more often.
Yeah, it's not ideal, but it is what it is. Working on improving how often we can update them.
@@ -36,11 +37,11 @@ The backend will switch to using a combination of archived dislike stats, estima
### **5. How is the dislike count calculated?**
RYD uses the votes from it's users to extrapolate the dislike count.
RYD uses the votes from its users to extrapolate the dislike count.
- If the video was uploaded after the API was shut down:
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Like Count}}{\textup{RYD Users Dislike Count}} \right) \times \textup{Public Like Count} $$
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
- If the RYD database somehow had the actual like and dislike count (provided by the uploader or from the archive), the dislike count will be calculated based on both - the users' votes and the archived value. The archived value will have less influence on the final count as it ages.
+61
View File
@@ -0,0 +1,61 @@
اقرأ هذا بلغات أخرى: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
# الأسئلة الشائعة
## قبل طرح سؤال على GitHub أو Discord، يرجى الرجوع إلى هذا.
<br>
### **1. من أين تحصل هذه الإضافة على البيانات؟**
مزيج من واجهات برمجة تطبيقات Google والبيانات المستخرجة.
نحن نحفظ جميع البيانات المتاحة في قاعدة بياناتنا لتكون متاحة بعد أن تقوم Google بإيقاف عداد عدم الإعجاب في واجهة برمجة التطبيقات الخاصة بهم.
<br>
### **2. عداد عدم الإعجاب بالفيديو لا يتم تحديثه**
حاليًا، يتم تخزين عدم الإعجاب بالفيديو مؤقتًا، ولا يتم تحديثه بشكل متكرر. مرة كل 2-3 أيام، وليس أكثر.
نعم، إنه ليس مثاليًا، لكنه ما هو عليه. نعمل على تحسين عدد مرات التحديث.
<br>
### **3. كيف يعمل هذا؟**
تجمع الإضافة معرف الفيديو الذي تشاهده، وتسترجع عدم الإعجاب (وحقول أخرى مثل المشاهدات، الإعجابات، إلخ) باستخدام واجهة برمجة التطبيقات الخاصة بنا، إذا كانت هذه هي المرة الأولى التي يتم فيها استرجاع الفيديو بواسطة واجهة برمجة التطبيقات الخاصة بنا، فستستخدم واجهة برمجة تطبيقات YouTube للحصول على البيانات، ثم تخزن البيانات في قاعدة بيانات للتخزين المؤقت (مخزنة مؤقتًا لمدة 2-3 أيام) ولأغراض الأرشفة وتعيدها إليك. ثم تعرض الإضافة عدم الإعجاب لك.
<br>
### **4. ماذا سيحدث بعد أن تتوقف واجهة برمجة تطبيقات YouTube عن إرجاع عداد عدم الإعجاب؟**
سيتحول الخلفية إلى استخدام مزيج من إحصائيات عدم الإعجاب المؤرشفة، والتقديرات المستخلصة من بيانات مستخدمي الإضافة والتقديرات بناءً على نسب المشاهدة/الإعجاب للفيديوهات التي لم يتم أرشفة عدم الإعجاب بها ولأرشيفات عدم الإعجاب القديمة.
<br>
### **5. كيف يتم حساب عداد عدم الإعجاب؟**
تستخدم RYD أصوات مستخدميها لاستخلاص عداد عدم الإعجاب.
- إذا تم تحميل الفيديو بعد إيقاف واجهة برمجة التطبيقات:
$$ \textup{عداد عدم الإعجاب في RYD} = \left( \frac{\textup{عداد عدم الإعجاب لمستخدمي RYD}}{\textup{عداد الإعجاب لمستخدمي RYD}} \right) \times \textup{عداد الإعجاب العام} $$
- إذا كانت قاعدة بيانات RYD تحتوي بطريقة ما على عداد الإعجاب وعدم الإعجاب الفعلي (مقدم من الناشر أو من الأرشيف)، فسيتم حساب عداد عدم الإعجاب بناءً على كل من - أصوات المستخدمين والقيمة المؤرشفة. سيكون للقيمة المؤرشفة تأثير أقل على العد النهائي مع مرور الوقت.
<br>
---
هذا في شكل فيديو
[![شرح IReturn YouTube Dislike](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## لدي مخاوف بشأن الأمان / الخصوصية
انظر [هذه الصفحة](SECURITY-FAQ.md) لمزيد من المعلومات.
+57
View File
@@ -0,0 +1,57 @@
Bunu digər dillərdə oxuyun: [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md)
# Tez-tez verilən suallar
## GitHub və ya Discord-da sual verməzdən əvvəl bura nəzər salın.
<br>
### **1.Bu uzantı məlumatları haradan əldə edir??**
Google API-lərin və silinmiş məlumatların birləşməsi.Google API-lərin və silinmiş məlumatların birləşməsi.
Google öz API-də bəyənməmə saylarını bağladıqdan sonra istifadə oluna bilməsi üçün bütün mövcud məlumatları verilənlər bazamızda saxlayırıq.
<br>
### **2. Videonun bəyənməmə sayı yenilənmir**
Hazırda videonun bəyənməmələri keşlənir və tez-tez yenilənmir. Hər 2-3 gündə, daha tez-tez deyil.
Bəli, bu ideal deyil, amma olduğu kimidir. Biz bunları daha tez-tez necə yeniləyə biləcəyimizi anlamaq üzərində işləyirik.
<br>
### **3. Bu uzantı necə işləyir?**
Artırma bizim API-dən istifadə edərək baxdığınız videonun video identifikatorunu götürür, bəyənmədiklərini (və baxışlar, bəyənmələr və s. kimi digər sahələri) alır; video API tərəfindən ilk dəfə alınırsa, YouTube API-dən istifadə edir. Məlumatları əldə etmək üçün o, keşləmə (təxminən 2-3 gün ərzində yaddaşda saxlanılır) və arxivləşdirmə məqsədləri üçün verilənlər bazasında saxlanılır və sizə qaytarılır. Daha sonra uzantı sizə bəyənmədiyinizləri göstərir.
<br>
### **4. YouTube API bəyənməmə saylarını qaytarmağı dayandırdıqda nə baş verir?**
Backend arxivləşdirilmiş bəyənməmə statistikası, genişləndirmə istifadəçi məlumatlarından ekstrapolyasiya edilmiş təxminlər və arxivləşdirilməmiş videolar və köhnə bəyənməmə arxivləri üçün baxış/bəyənmə nisbətlərinə əsaslanan təxminlərin birləşməsindən istifadə etməyə keçəcək.
<br>
### **5. Bəyənməmələrin sayı necə hesablanır?**
YDS bəyənməmələrin sayını hesablamaq üçün istifadəçilərinin səslərindən istifadə edir.
- Video API bağlandıqdan sonra yüklənibsə:
$$ \textup{YDS Bəyənməmə Sayısı} = \left( \frac{\textup{YDS İstifadəçiləri Saymağı Bəyənirlər}}{\textup{YDS İstifadəçilərinin Bəyənmədiyi Say}} \right) \times \textup{İctimai Bəyənmə Sayısı} $$
- Əgər YDS verilənlər bazasında hansısa şəkildə real bəyənmə və bəyənməmə sayları varsa (yükləyən və ya arxivdən təqdim olunur), bəyənməmə sayı həm istifadəçilərin səsləri, həm də arxivləşdirilmiş dəyər əsasında hesablanacaq. Arxivləşdirilmiş dəyər yaşlandıqca son hesaba daha az təsir edəcək.
<br>
---
Bu video formada
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Məxfilik/təhlükəsizliklə bağlı narahatlıqlarım var
Ətraflı məlumat üçün [bu səyfə](SECURITY-FAQtr.md)ya baxın.
+62
View File
@@ -0,0 +1,62 @@
Прочетете това на други езици: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md)
# Често задавани въпроси
## Преди да зададете въпрос в GitHub или Discord, моля, обърнете се към това.
<br>
### **1. Откъде взема разширението тези данни?**
Комбинация от Google APIs и скрейпнати данни.
Запазваме всички налични данни в нашата база данни, за да бъдат налични след като Google затвори броя на дизлайковете в техния API.
<br>
### **2. Броят на дизлайковете на видеото не се обновява**
В момента дизлайковете на видеата се кешират и не се обновяват много често. Веднъж на всеки 2-3 дни, не по-често.
Да, не е идеално, но каквото, такова. Работим по подобрение на това, колко често можем да ги актуализираме.
<br>
### **3. HКак работи това?**
Разширението взима video id на видеото, което гледате, извлича дизлайковете (и други полета като гледания, харесвания и т.н.) чрез нашия API. Ако това е първият път, когато видеото беше извлечено от нашия API, той ще използва YouTube API, за да получи данните, след което ще ги запази в база данни за кеширане (кеширане за около 2-3 дни) и архивиране и ще ги върне. Разширението след това ви показва дизлайковете.
<br>
### **4. Какво ще стане, след като YouTube API спре да връща броя на дизлайковете?**
Бекендът ще премине към използване на комбинация от архивирани статистики за дизлайкове, оценки екстраполирани от данните на потребителите на разширението и оценки на базата на съотношението гледания/харесвания за видеа, чиито дизлайкове не са били архивирани и за стари архиви с дизлайкове.
<br>
### **5. Как се изчислява броят на дизлайковете?**
RYD използва гласовете от своите потребители, за да екстраполира броя на дизлайковете.
- Ако видеото е качено след като API е затворил:
$$ \textup{RYD Брой на дизлайковете} = \left( \frac{\textup{RYD Брой на дизлайковете от потребителите}}{\textup{RYD Брой на харесвания от потребителите}} \right) \times \textup{Общ брой харесвания} $$
- Ако базата данни на RYD по някакъв начин има реалния брой на харесвания и дизлайкове (предоставени от качителя или от архива), броят на дизлайковете ще бъде изчислен на базата и на гласовете на потребителите, и на архивната стойност. Архивната стойност ще има по-малко влияние върху крайния брой, докато остарее.
<br>
---
Това във видео форма
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Имам опасения относно сигурността / личните данни
Вижте [тази страница](SECURITY-FAQbg.md) за повече информация.
+62
View File
@@ -0,0 +1,62 @@
以其他语言阅读: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
# 常见问题
## 在 GitHub 或 Discord 上提问之前,请先参阅此内容.
<br>
### **1. 此扩展程序从哪里获取数据?**
Google API 和抓取数据的组合。
我们将所有可用数据保存到我们的数据库中,以便 Google 关闭其 API 中的不喜欢计数后仍可用。
<br>
### **2. 视频不喜欢计数未更新**
目前,视频的点赞信息会被缓存,更新频率不高。每 2-3 天更新一次,不会更频繁。
这并不理想,但这就是现状。我们正在努力提高更新频率。
<br>
### **3. 这是如何运作的?**
该扩展程序会收集您正在观看的视频的视频 ID,使用我们的 API 获取不喜欢的内容(以及其他字段,如观看次数、喜欢次数等),如果这是我们的 API 首次获取该视频,它将使用 YouTube API 获取数据,然后将数据存储在数据库中以供缓存(缓存约 2-3 天)和存档,并将其返回给您。然后,该扩展程序会向您显示不喜欢的内容。
<br>
### **4. YouTube API 停止返回不喜欢计数后会发生什么?**
后端将切换到使用已存档的不喜欢统计数据、从扩展用户数据推断出的估计值以及基于未存档的不喜欢的视频和过时的不喜欢存档的观看/喜欢比率的估计值的组合。
<br>
### **5. 如何计算不喜欢数?**
RYD 使用用户的投票来推断不喜欢的数量。
- 如果视频是在 API 关闭后上传的:
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
- 如果 RYD 数据库以某种方式拥有实际的喜欢和不喜欢计数(由上传者提供或来自存档),则不喜欢计数将根据用户的投票和存档值计算。存档值随着时间的推移对最终计数的影响会越来越小。
<br>
---
这是视频形式
[![IReturn YouTube 不喜欢解释](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## I have security / privacy concerns
有关详细信息,请参阅[此页面](SECURITY-FAQ.md)。
+61
View File
@@ -0,0 +1,61 @@
Read this in other languages: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md)
# Häufig gestellte Fragen
## Bevor Sie eine Frage auf GitHub oder Discord stellen, sehen Sie bitte hier nach.
<br>
### **1. Woher stammt die Datenquelle dieser Erweiterung?**
Eine Kombination aus Google-APIs und gescrapten Daten.
Wir speichern alle verfügbaren Daten in unserer Datenbank, damit sie verfügbar sind, nachdem Google die Anzahl der Dislikes in ihrer API abschaltet.
<br>
### **2. Die Anzahl der Dislikes bei Videos aktualisiert sich nicht**
Aktuell werden die Dislikes bei Videos zwischengespeichert und nicht sehr häufig aktualisiert. Etwa alle 2-3 Tage, nicht öfter.
Ja, das ist nicht ideal, aber es ist wie es ist. Wir arbeiten daran, wie oft wir sie aktualisieren können, zu verbessern.
<br>
### **3. Wie funktioniert das?**
Die Erweiterung sammelt die Video-ID des von Ihnen angesehenen Videos, ruft die Dislikes (und andere Felder wie Aufrufe, Likes usw.) über unsere API ab. Wenn dies das erste Mal ist, dass das Video von unserer API abgerufen wurde, wird die YouTube-API verwendet, um die Daten zu erhalten. Anschließend werden die Daten für den Zwischenspeicher (ca. 2-3 Tage zwischengespeichert) und Archivierungszwecke in einer Datenbank gespeichert und an Sie zurückgegeben. Die Erweiterung zeigt Ihnen dann die Dislikes an.
<br>
### **4. Was passiert, wenn die YouTube-API aufhört, die Anzahl der Dislikes zurückzugeben?**
Das Backend wird auf eine Kombination aus archivierten Dislike-Statistiken, Schätzungen, die aus den Daten der Erweiterungsnutzer extrapoliert werden, und Schätzungen basierend auf Ansichts-/Like-Verhältnissen für Videos, deren Dislikes nicht archiviert wurden, und veralteten Dislike-Archiven umstellen.
<br>
### **5. Wie wird die Anzahl der Dislikes berechnet?**
RYD verwendet die Stimmen seiner Benutzer, um die Anzahl der Dislikes zu extrapolieren.
- Wenn das Video nach dem Abschalten der API hochgeladen wurde:
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
- Wenn die RYD-Datenbank auf irgendeine Weise die tatsächliche Anzahl von Likes und Dislikes enthielt (vom Ersteller bereitgestellt oder aus dem Archiv), wird die Anzahl der Dislikes basierend auf beiden - den Stimmen der Benutzer und dem archivierten Wert - berechnet. Der archivierte Wert wird mit zunehmendem Alter weniger Einfluss auf die endgültige Zählung haben.
<br>
---
Dies in Videoform
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Ich habe Sicherheits-/Privatsphärebedenken
Siehe [diese Seite](SECURITY-FAQde.md) für weitere Informationen.
+1 -1
View File
@@ -1,4 +1,4 @@
Lisez ceci dans d'autres langues : [English](FAQ.md)
Lisez ceci dans d'autres langues : [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Foire Aux Questions
+64
View File
@@ -0,0 +1,64 @@
Baca ini dibahasa lain: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
# Pertayaan yang Sering Ditanyakan
## Sebelum bertanya di GitHub atau Discord, tolong lihat halaman ini.
<br>
### **1. Darimana extension ini mendapatkan datanya?**
Kombinasi dari API Google dan scraping data.
Kami telah menyimpan semua data yang ada kedalam DB agar datanya tetap ada meskipun setelah Google menghapus jumlah dislike dari API mereka.
<br>
### **2. Jumlah dislike video tidak update**
Sekarang jumlah dislike video dicache, dan tidak update selalu.
Sekali setiap 2-3 hari, tidak lebih dari itu.
Benar, itu tidak ideal, tapi inilah yang bisa kami lakukan dan kami sedang melakukan improvisasi agar dapat melakukan update sesering mungkin.
<br>
### **3. Bagaimana cara kerjanya?**
Extension mengumpulkan id dari video yang sedang ditonton, mengambil data dislike (dan lainnya seperti jumlah views, likes, dll) menggunakan API kami, jika video ini pertama kali diambil datanya menggunakan API kami, API yang digunakan akan diubah ke API Youtube untuk mengambil datanya dan datanya akan disimpan kedalam database kami untuk dilakukan cache (untuk 2-3 hari) yang bertujuan untuk pengarsipan data dan datanya dikembalikan ke kamu. Lalu extension akan menampilkan jumlah dislike ke kamu.
<br>
### **4. Apa yang akan terjadi setelah API Youtube menghapus jumlah dislike?**
Bagian backend akan berganti dengan menggunakan kombinasi dari data arsip dislike, estimasi perkiraan dari pengguna, dan estimasi berdasarkan jumlah rasio view/like untuk video yang dislikenya tidak diarsip dan arsip dislike yang sudah tua.
<br>
### **5. Bagaimana cara menghitung jumlah dislike?**
RYD mernggunakan sistem voting dari pengguna untuk memperkirakan jumlah dislike.
- Jika video diupload setelah API dislike dihapus:
$$ \textup{Jumlah RYD Dislike} = \left( \frac{\textup{Jumlah RYD Dislike Pengguna}}{\textup{Jumlah RYD Like Pengguna}} \right) \times \textup{Jumlah Public Like Publik} $$
- Jika entah bagaimana Database RYD memiliki data jumlah asli dari like dan dislike (diberikan dari pengupload atau arsip), jumlah dislike akan dihitung berdasarkan jumlah voting dari pengguna dan arsip. Semakin tua data arsip maka semakin sedikit pengaruhnya ke hasil akhir perhitungan dislike.
<br>
---
Berikut adalah form video
[![Mengenai IReturn YouTube Dislike](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Saya memiliki masalah keamanan/privasi
Lihat [halaman ini](SECURITY-FAQid.md) untuk informasi lebih lanjut.
+61
View File
@@ -0,0 +1,61 @@
Lees dit in andere talen: [Engels](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Veel Gestelde Vragen
## Raadpleeg deze voordat u een vraag stelt op GitHub of Discord.
<br>
### **1. Waar haalt deze extensie de gegevens vandaan?**
Een combinatie van Google API's en geschraapte gegevens.
We slaan alle beschikbare gegevens op in onze database, zodat deze beschikbaar is nadat Google het aantal dislikes in hun API heeft stopgezet.
<br>
### **2. Het aantal video-dislikes wordt niet bijgewerkt**
Op dit moment worden video's die niet leuk zijn in het cachegeheugen opgeslagen en worden niet erg vaak bijgewerkt. Eens in de 2-3 dagen, niet vaker.
Ja, het is niet ideaal, maar het is wat het is. Werken aan het verbeteren van hoe vaak we ze kunnen bijwerken.
<br>
### **3. Hoe werkt dit?**
De extensie verzamelt de video-ID van de video die je aan het bekijken bent, haalt de dislike (en andere velden zoals views, likes etc) op met behulp van onze API. Als dit de eerste keer is dat de video wordt opgehaald door onze API, zal deze de YouTube API gebruiken om de gegevens op te halen, slaat de gegevens vervolgens op in een database voor caching (cache voor ongeveer 2-3 dagen) en archiveringsdoeleinden en stuurt ze naar u terug. De extensie geeft vervolgens de antipathieën aan u weer.
<br>
### **4. Wat gebeurt er nadat de YouTube API stopt met het teruggeven van het aantal dislikes?**
De backend zal overschakelen naar het gebruik van een combinatie van afkeer statistieken en afkeer archieven, schattingen geëxtrapoleerd uit gebruikersgegevens van extensies en schattingen op basis van weergave/vind-ik-leuk-verhoudingen voor video's waarvan de afkeuren niet zijn gearchiveerd en voor verouderde afkeer-archieven.
<br>
### **5. Hoe wordt het aantal dislikes berekend?**
RYD gebruikt de stemmen van zijn gebruikers om het aantal dislikes te extrapoleren.
- Als de video is geüpload nadat de API was afgesloten:
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Like Count}}{\textup{RYD Users Dislike Count}} \right) \times \textup{Public Like Count} $$
- Als de RYD-database op de een of andere manier het werkelijke aantal likes en dislikes had (geleverd door de uploader of uit het archief), wordt het aantal dislikes berekend op basis van zowel de stemmen van de gebruikers als de gearchiveerde waarde. De gearchiveerde waarde heeft minder invloed op de uiteindelijke telling naarmate deze ouder wordt.
<br>
---
Dit in videovorm
[![IReturn YouTube Dislike Uitgelegd (Engels)](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Ik heb zorgen over de beveiliging/privacy
Bekijk [deze pagina](SECURITY-FAQnl.md) voor meer informatie.
+61
View File
@@ -0,0 +1,61 @@
Read this in other languages: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Często zadawane pytania
## Przeczytaj poniższe przed zadawaniem pytań na GitHubie lub Discordzie.
<br>
### **1.Skąd rozszerzenie otrzymuje swoje dane?**
Kombinacja API Google i danych scrape-owanych.
Zapisujemy wszystkie dostępne dane do naszej bazy danych, żeby były dostępne po tym jak Google wyłączy liczniki łapek w dół w swoim API.
<br>
### **2. Licznik łapek w dół się nie aktualizuje**
Na chwile obecną łapki w dół są buforowane i nie są bardzo często aktualizowane. Raz na 2-3 dni, nie częściej.
No nie jest to idealne, ale tak już jest. Pracujemy nad tym jak częściej możemy to aktualizować.
<br>
### **3. Jak to działa?**
Rozszerzenie zbiera ID filmu, którego oglądasz, pobiera ilość łapek w dół (i inne pola, takie jak wyświetlenia, łapki w górę itd.) za pomocą naszego API. Jeżeli film jest pierwszy raz pobrany przez nasze API, zostanie użyte YouTube API do pobrania danych, a potem przechowania w bazie danych do buforu (przez jakieś 2-3 dni) i archiwizacji, po czym zwracane jest Tobie. Rozszerzenie potem wyświetla ilość łapek w dół.
<br>
### **4. Co się wydarzy gdy YouTube API przestanie zwracać liczbę łapek w dół?**
Backend przełączy się na używanie kombinacji zarchiwizowanych statystyk łapek w dół, szacunków ekstrapolowanych z danych użytkowników rozszerzenia i szacowań opartych na stosunkach wyświetleń/łapek w górę dla filmów, których ilość łapek w dół nie została zarchiwizowana i dla przestarzałych archiwów.
<br>
### **5. Jak wyliczana jest liczba łapek w dół?**
RYD używa głosów użytkowników, aby ekstrapolować liczbę łapek w dół.
- Jeżeli film został wrzucony przed wyłączeniem API:
$$ \textup{Liczba łapek w dół RYD} = \left( \frac{\textup{Liczba łapek w dół użytkowników RYD}}{\textup{Liczba łapek w górę użytkowników RYD}} \right) \times \textup{Publiczna liczba łapek w górę} $$
- Jeśli baza danych RYD jakimś cudem miałaby prawidłową liczbę łapek w górę i dół (zapewnione przez twórce lub z archiwum), liczba łapek w dół będzie obliczana w oparciu jednocześnie głosów użytkowników i wartości zarchiwizowanych. Zarchiwizowana wartość będzie miała mniejszy wpływ na ostateczną liczbę z biegiem czasu.
<br>
---
To samo w formie filmu.
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Mam zastrzeżenia co do bezpieczeństwa / prywatności
Przejdź [tutaj](SECURITY-FAQ.md) aby uzyskać więcej informacji.
+61
View File
@@ -0,0 +1,61 @@
Leia isso em outros Idiomas: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md)
# Frequently Asked Questions
## Before asking a question on GitHub or Discord, please refer to this.
<br>
### **1. Where does this extension get the data?**
A Combination of Google APIs and scraped data.
We save all available data to our DB for it to be available after Google shuts down dislike counts in their API.
<br>
### **2. Video dislike count doesn't update**
Right now video dislikes are cached, and aren't updated very frequenly. Once every 2-3 days, not more often.
Yeah, it's not ideal, but it is what it is. Working on improving how often we can update them.
<br>
### **3. How does this work?**
The extension collects the video id of the video you are watching, fetches the dislike (and other fields like views, likes etc) using our API, if this is the first time the video was fetched by our API, it will use the YouTube API to get the data, then stores the data in a database for caching (cached for around 2-3 days) and archiving purposes and returns it to you. The extension then displays the dislikes to you.
<br>
### **4. What will happen after the YouTube API stops returning the dislike count?**
The backend will switch to using a combination of archived dislike stats, estimates extrapolated from extension user data and estimates based on view/like ratios for videos whose dislikes weren't archived and for outdated dislike archives.
<br>
### **5. How is the dislike count calculated?**
RYD uses the votes from its users to extrapolate the dislike count.
- If the video was uploaded after the API was shut down:
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
- If the RYD database somehow had the actual like and dislike count (provided by the uploader or from the archive), the dislike count will be calculated based on both - the users' votes and the archived value. The archived value will have less influence on the final count as it ages.
<br>
---
Essa a This in video form
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## I have security / privacy concerns
See [this page](SECURITY-FAQpt_BR.md) for more info.
+29 -21
View File
@@ -1,21 +1,29 @@
# Часто задаваемые вопросы
## Прежде чем задать вопрос на GitHub или в Discord, пожалуйста, ознакомьтесь с этим.
### **1. Откуда это расширение получает данные?**
Комбинация API Google и старых данных.
Мы сохраняем все имеющиеся данные в нашей базе данных, чтобы они были доступны после того, как Google прекратит подсчёт отметок «Не нравится» в своём API.
### **2. Количество не понравившихся видео не обновляется**
В настоящее время видео с отметками «Не нравится» кэшируются и обновляются не очень часто. Раз в 2-3 дня, не чаще.
Да, это не идеально, но это то, что есть. Мы работаем над тем, чтобы улучшить частоту их обновления.
### **3. Как это работает?**
Расширение собирает идентификатор видео, которое вы смотрите, извлекает данные об отметках «Не нравится» (и другие поля, такие как просмотры, отметки «Нравится» и т.д.) с помощью нашего API, если видео было извлечено нашим API впервые, оно использует YouTube API для получения данных, затем сохраняет данные в базе данных для кэширования (кэшируются около 2-3 дней) и архивирования и возвращает их вам. После этого расширение отображает отметки «Не нравится» вам.
### **4. Что произойдёт после того, как API YouTube перестанет возвращать данные о количестве отметок «Не нравится»?**
Внутренняя часть нашего сервера переключится на использование комбинации архивных статистик отметок «Не нравится», оценок, экстраполированных из данных о пользователях расширения, и оценок, основанных на соотношении просмотров и отметок «Нравится» для видео, чьи отметки «Не нравится» не были заархивированы, и для устаревших архивов с отметками «Не нравится».
## Я беспокоюсь о безопасности / конфиденциальности
Более подробную информацию смотрите на [этой странице](SECURITY-FAQ.md).
Read this in other languages: [English](FAQ.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Часто задаваемые вопросы
## Прежде чем задать вопрос на GitHub или в Discord, пожалуйста, ознакомьтесь с этим.
### **1. Откуда это расширение получает данные?**
Комбинация API Google и старых данных.
Мы сохраняем все имеющиеся данные в нашей базе данных, чтобы они были доступны после того, как Google прекратит подсчёт отметок «Не нравится» в своём API.
### **2. Количество не понравившихся видео не обновляется**
В настоящее время видео с отметками «Не нравится» кэшируются и обновляются не очень часто. Раз в 2-3 дня, не чаще.
Да, это не идеально, но это то, что есть. Мы работаем над тем, чтобы улучшить частоту их обновления.
### **3. Как это работает?**
Расширение собирает идентификатор видео, которое вы смотрите, извлекает данные об отметках «Не нравится» (и другие поля, такие как просмотры, отметки «Нравится» и т.д.) с помощью нашего API, если видео было извлечено нашим API впервые, оно использует YouTube API для получения данных, затем сохраняет данные в базе данных для кэширования (кэшируются около 2-3 дней) и архивирования и возвращает их вам. После этого расширение отображает отметки «Не нравится» вам.
### **4. Что произойдёт после того, как API YouTube перестанет возвращать данные о количестве отметок «Не нравится»?**
Внутренняя часть нашего сервера переключится на использование комбинации архивных статистик отметок «Не нравится», оценок, экстраполированных из данных о пользователях расширения, и оценок, основанных на соотношении просмотров и отметок «Нравится» для видео, чьи отметки «Не нравится» не были заархивированы, и для устаревших архивов с отметками «Не нравится».
## Я беспокоюсь о безопасности / конфиденциальности
Более подробную информацию смотрите на [этой странице](SECURITY-FAQ.md).
+61
View File
@@ -0,0 +1,61 @@
Bunu diğer dillerde okuyun: [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Sıkça Sorulan Sorular
## GitHub'da veya Discord'da bir soru sormadan önce, lütfen buraya göz atın.
<br>
### **1. Bu uzantı verileri nereden alıyor?**
Google API'lerinin ve kazınmış verilerin bir kombinasyonu.
Google, API'lerinde dislike sayılarını kapattıktan sonra kullanılabilir olması için mevcut tüm verileri DB'mize kaydederiz.
<br>
### **2. Video'nun dislike sayısı güncellenmiyor**
Şu anda video dislike'ları önbelleğe alınır ve çok sık güncellenmez. Her 2-3 günde bir, daha sık değil.
Evet, ideal değil, ama olan bu. Bunları nasıl daha sık güncelleyebileceğimizi öğrenmeye çalışıyoruz.
<br>
### **3. Bu uzantı nasıl çalışıyor?**
Uzantı, izlediğiniz videonun video kimliğini alır, dislike'larını (ve görüntülemeleri, like'ları vb. diğer alanları) API'mizi kullanarak getirir; video, API'miz tarafından ilk kez getiriliyorsa YouTube API'sini kullanır. Verileri almak için, verileri önbelleğe alma (yaklaşık 2-3 gün önbelleğe alınır) ve arşivleme amacıyla bir veritabanında saklanır ve size geri döndürülür. Uzantı daha sonra size dislike'ları gösterir.
<br>
### **4. YouTube API'si, dislike sayısını döndürmeyi durdurduğunda ne olacak?**
Backend, arşivlenmiş dislike istatistikleri, uzantı kullanıcı verilerinden tahmin edilen tahminler ve like'ları arşivlenmemiş videolar ve eski dislike arşivleri için izlenme/like oranlarına dayalı tahminlerin bir kombinasyonunu kullanmaya geçecektir.
<br>
### **5. Dislike sayısı nasıl hesaplanıyor?**
YDS, dislike sayısını tahmin etmek için kullanıcılarının oylarını kullanır.
- Video, API kapatıldıktan sonra yüklendiyse:
$$ \textup{YDS'nin Dislike Sayısı} = \left( \frac{\textup{YDS Kullanıcılarının Like Sayısı}}{\textup{YDS Kullanıcılarının Dislike Sayısı}} \right) \times \textup{Halka Açık Like Sayısı} $$
- YDS veritabanı bir şekilde gerçek like ve dislike sayısına sahipse (yükleyici tarafından veya arşivden sağlanır), dislike sayısı hem kullanıcıların oyları hem de arşivlenen değer temelinde hesaplanacaktır. Arşivlenen değer, eskidikçe son sayım üzerinde daha az etkiye sahip olacaktır.
<br>
---
Bu video şeklinde
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Gizlilik / güvenlik hakkında endişelerim var
Daha fazla bilgi için [bu sayfa](SECURITY-FAQtr.md)ya göz atın.
+61
View File
@@ -0,0 +1,61 @@
Read this in other languages: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
# Часті питання
## Перш ніж задавати питання на GitHub або у Discord, будь ласка, ознайомтеся з цим.
<br>
### **1. Звідки це розширення отримує дані?**
Комбінація Google API та старих даних.
Ми зберігаємо всі доступні дані в нашій базі, аби вони були доступні після того, як Google вимкне лічильник відміток «Не подобається» у своєму API.
<br>
### **2. Лічильник «Не подобається» не оновлюється**
Наразі відео з відмітками «Не подобається» кешуються і оновлюються не надто часто. Не частіше, ніж раз в 2-3 дні.
Так, це не ідеально, але маємо те, що маємо. Ми працюємо над тим, щоб збільшити частоту їх оновлення.
<br>
### **3. Як це працює?**
Розширення отримує ID відео, яке ви переглядаєте, та дізнається кількість відміток «Не подобається» (та інші дані: перегляди, відмітки «Подобається» тощо) за допомогою нашого API, якщо відео було витягнуте нашим API вперше, воно використовує YouTube API для отримання даних, потім зберігає дані в базі даних для кешування (кешуються близько 2-3 днів) та архівування й повертає їх вам. Після цього розширення відображає відмітки «Не подобається» вам.
<br>
### **4. Що станеться після того, як YouTube API перестане повертати кількість відміток «Не подобається»?**
Сервер перейде на використання комбінації заархівованих статистичних даних відміток «Не подобається» екстрапольованих із даними .
<br>
### **5. Як розраховується кількість відміток «Не подобається»?**
RYD використовує відмітки своїх користувачів, щоб екстраполювати кількість відміток «Не подобається».
- Якщо відео було завантажено після вимкнення API:
$$ \textup{К-ть відміток «Не подобається» у RYD} = \left( \frac{\textup{К-ть відміток «Подобається» серед користувачів RYD}}{\textup{К-ть відміток «Не подобається» серед користувачів RYD}} \right) \times \textup{Публічна к-ть відміток «Подобається»} $$
- Якщо база даних RYD якимось чином мала фактичну кількість відміток «Подобається» та «Не подобається» (надану завантажувачем або з архіву), кількість відміток «Не подобається» буде розраховано на основі відміток користувачів і архівного значення. Архівне значення матиме менший вплив на остаточний підрахунок у міру актуальності.
<br>
---
Це все, але у відео форматі
[![IReturn YouTube Dislike Explained](https://yt-embed.herokuapp.com/embed?v=GSmmtv-0yYQ)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Я турбуюся про безпеку/конфіденційність
Детальніше про це дивіться [тут](SECURITY-FAQuk.md).
+62
View File
@@ -0,0 +1,62 @@
Đọc bằng các ngôn ngữ khác: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md)
# Câu Hỏi Thường Gặp <!-- # Frequently Asked Questions -->
## Trước khi đưa ra bất kì câu hỏi nào trên GitHub hay trên Discord, vui lòng đọc hết trang này. <!-- ## Before asking a question on GitHub or Discord, please refer to this. -->
<br>
### **1. Tiện ích mở rộng này lấy dữ liệu từ đâu?** <!-- ### Where does this extension get the data? -->
Kết hợp dữ liệu lấy từ API của Google và dữ liệu thu thập được.
Chúng tôi lưu giữ tất cả dữ liệu mà chúng tôi có được vào cơ sở dữ liệu của chúng tôi, kể từ khi Google loại bỏ số lượt đánh giá "không thích" khỏi API của họ.
<br>
### **2. Số lượt "không thích" không được cập nhật** <!-- ### Video dislike count doesn't update -->
Hiện tại, các lượt đánh giá "không thích" sẽ được lưu lại và số lượt đánh giá tổng được hiển thị không được cập nhật thường xuyên. Thông thường, cứ mỗi 2 đến 3 ngày, chứ không ngắn hơn.
Đồng ý rằng điều này không đáng mong chờ, nhưng hiện tại thì tiện ích hoạt động như vậy. Chúng tôi đang cố gắng cải thiện tần suất nhật số lượt đánh giá.
<br>
### **3. Cách thức hoạt động của tiện ích mở rộng này?** <!-- ### How does this work? -->
Tiện ích này thu thập ID của vi-đê-ô mà bạn đang xem, truy vấn đánh giá của vi-đê-ô (bao gồm số lượt "không thích", số lượt "thích", số lượt xem, v.v.) bằng API của chúng tôi. Nếu đây là đầu tiên diễn ra truy vấn tới API của chúng tôi đối với vi-đê-ô này, tiện ích sẽ sử dụng API của YouTube sẽ truy vấn các thông tin cần thiết, sau đó lưu trữ các thông tin này vào cơ sở dữ liệu cho mục đích truy vấn nhanh (trong vòng 2 đến 3 ngày) và cho mục đích lưu trữ và sẽ trả kết quả truy vấn về cho bạn. Lúc này, tiện ích sẽ hiện thị số lượt đánh giá "không thích" cho bạn xem.
<br>
### **4. Việc gì sẽ diễn ra sau khi API của YouTube ngừng cung cấp số lượt "không thích"?** <!-- ### What will happen after the YouTube API stops returning the dislike count? -->
Đầu sau của tiện ích sẽ chuyển sang sử dụng kết hợp dữ liệu đã được lưu trữ về số lượt đánh giá "không thích", những ước tính ngoại suy từ dữ liệu người dùng của tiện ích và những ước tính dựa trên tỉ lệ lượt xem/lượt "thích" đối với những vi-đê-ô chưa lưu được số lượt "không thích" và đối với các kho lưu trữ số lượt "không thích" lỗi thời.
<br>
### **5. Số lượt đánh giá "không thích" được tính như thế nào?** <!-- ### How is the dislike count calculated? -->
RYD sử dụng đánh giá từ người dùng để ngoại suy số lượt đánh giá "không thích".
- Nếu vi-đê-ô được đăng tải sau khi API của YouTube loại bỏ trường `dislike`:
$$ \textup{Số lượt "Không thích" của RYD} = \left( \frac{\textup{Số lượt "Không thích" từ Người dùng của RYD}}{\textup{Số lượt "Thích" từ Người dùng của RYD}} \right) \times \textup{Số lượt "Thích" từ API của YouTube} $$
- Nếu cơ sở dữ liệu của RYD có được số lượt đánh giá "thích" và "không thích" thực (được cung cấp bởi chủ vi-đê-ô hay từ kho lưu trữ), số lượt đánh giá "không thích" sẽ được tính dựa trên đồng thời (1) đánh giá từ người dùng của tiện ích và (2) số liệu được lưu trữ. Số liệu được lưu trữ sẽ càng ngày càng kém quan trọng trong việc tính toán số lượt đánh giá.
<br>
---
Vi-đê-ô thông tin về Return YouTube Dislike:
[Giải thích về Return YouTube Dislike (Tiếng Anh)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
---
<br>
## Tôi có thắc mắc về tính bảo mật / quyền riêng tư <!-- ## I have security / privacy concerns -->
Vui lòng tham khảo [trang này](SECURITY-FAQvi.md) để biết thêm thông tin.
+320
View File
@@ -0,0 +1,320 @@
**Mục lục**
- [Downloading](#downloading)
- [Desktop (all OS supported by these browsers)](#desktop-all-os-supported-by-these-browsers)
- [Chromium Based Browsers](#chromium-based-browsers)
- [Firefox Based Browsers](#firefox-based-browsers)
- [Mobile](#mobile)
- [Android](#android)
- [iOS](#ios)
- [Userscript](#userscript)
- [Installation](#installation)
- [Desktop](#desktop)
- [**Chromium based browsers**](#chromium-based-browsers-1)
- [From Chrome Webstore](#from-chrome-webstore)
- [From crx/zip file](#from-crxzip-file)
- [From unzipped folder](#from-unzipped-folder)
- [**Firefox Based Browsers**](#firefox-based-browsers-1)
- [From addon store](#from-addon-store)
- [From xpi/jar/zip file](#from-xpijarzip-file)
- [Mobile](#mobile-1)
- [Android](#android-1)
- [App from Play Store](#app-from-play-store)
- [On Firefox](#on-firefox)
- [iOS](#ios-1)
- [Using](#using)
- [Updating](#updating)
- [Extension / Addon](#extension--addon)
- [Miscellaneous](#miscellaneous)
- [Using YouTube website as an app with an extension](#using-youtube-website-as-an-app-with-an-extension)
- [Desktop](#desktop-1)
- [Chromium Based Browsers](#chromium-based-browsers-2)
- [Firefox Based Browsers](#firefox-based-browsers-2)
- [Mobile](#mobile-2)
- [Firefox Based Browsers](#firefox-based-browsers-3)
- [Chromium Based Browsers](#chromium-based-browsers-3)
<br>
<br>
## Downloading
### Desktop (all OS supported by these browsers)
<br>
#### [Chromium Based Browsers][4]
This extension has been tested to work on these browsers.
- [Google Chrome][1]
- [Microsoft Edge][1]
- [Brave][1]
- [Opera][1]
It should be able to work on [all Chromium-based browsers (list here)][4]. But that isn't guaranteed.
<br>
#### [Firefox Based Browsers][5]
- [Firefox][2]
- This addon should be able to run on most of the [Firefox-based browsers][5]. But isn't guaranteed.
<br>
### Mobile
#### Android
1. **F-Droid Store**
- [Show Youtube Dislikes](https://f-droid.org/en/packages/com.jesperh.showyoutubedislikes/)
[Download from here (Click here)](https://f-droid.org/en/packages/com.jesperh.showyoutubedislikes/)
The source code is available at [github.com/jesperbakhandskemager/view-youtube-dislike](https://github.com/jesperbakhandskemager/view-youtube-dislike)
You can download the apk file from
[https://github.com/jesperbakhandskemager/view-youtube-dislike/releases/](https://github.com/jesperbakhandskemager/view-youtube-dislike/releases/)
**Note: This app is NOT made by the original author of the extension**.
2. [**Firefox Nightly**][2]
- This addon should be able to run on most of the [Firefox-based browsers][5]. But isn't guaranteed.
3. [**Chromium Based browsers**][4]
Most [**Chromium Based browsers**][4] **don't support extensions on Android or iOS**
However, [Kiwi Browser](https://kiwibrowser.com/) does. You can refer to this video - [Google Chrome Extensions on Android with Kiwi Browser!](https://youtu.be/T6J0T_-oim4)
<br>
#### iOS
No Support on Firefox
You can have a look at these pages for more information (the reason why it's not available on Firefox):
- [https://support.mozilla.org/en-US/kb/add-ons-firefox-ios]
- [https://support.mozilla.org/en-US/questions/1101350]
For now, you can try this
- [For Jailbroken iOS - **WE TAKE NO RESPONSIBILITY. USE AT YOUR OWN RISK**](https://chariz.com/get/return-youtube-dislike)
This is an iOS port [**developed by a separate team**](https://github.com/PoomSmart/Return-YouTube-Dislikes) not related to the owner of github.com/Anarios/return-youtube-dislike
#### Userscript
[Download from here](https://returnyoutubedislike.com/install)
<br>
<br>
## Installation
### Desktop
<br>
#### [**Chromium based browsers**][4]
<br>
##### From Chrome Webstore
1. [Go to website (click here)][1]
2. Click install
3. Wait for the extension to download and
4. 🙂 Installed !!
<br>
##### From crx/zip file
1. Download the crx/zip file.
2. Type [`chrome://extensions`][6] in address bar
3. Search for the "Developer Mode" switch and turn it on.
4. Open the folder and the browser side by side.
5. Drag and drop the crx/zip file in [chrome://extensions][6] tab
6. Click on "Add extension"
7. Installation Completed 🎉
8. Remember to **turn off the "developer mode"** if not needed
<br>
##### From unzipped folder
1. Download the crx/zip file.
2. Go to [`chrome://extensions`][6]
3. Search for the "Developer Mode" switch and turn it on.
4. Click on "Load Unpacked"
5. Navigate to the folder & select it
6. The extension should be installed 🎉
7. Remember to **turn off the "developer mode"** if not needed
<br>
#### [**Firefox Based Browsers**][5]
##### From addon store
1. Go to the addon store and find the extension or [click here][2].
2. Click on `Add to Firefox`
3. Done 🎉 The extension should be installed.
<br>
##### From xpi/jar/zip file
1. Type `about:addons` in the address bar and press "Enter". Alternatively, You can use
`Ctrl` + `Shift` + `A` on Windows and
`Cmd` + `Shift` + `A` on Mac and
2. Click on the Setting Gear icon `⚙`.
3. Click on `Install Add-on From File...`
4. Locate and click on the .xpi/.jar/.zip file.
5. Select and click to open it.
6. Done 🎉 The extension should be installed.
<br>
### Mobile
#### Android
##### App from Play Store
- Although app(s) which use this API exist, they aren't official.
##### On Firefox
1. Install Firefox Nightly. Refer to this blog post for the procedure: [Expanded extension support in Firefox for Android Nightly](https://blog.mozilla.org/addons/2020/09/29/expanded-extension-support-in-firefox-for-android-nightly/)
2. Install the extension in it from [addon store][2]
3. If you want - install the site as an app. For that procedure [go here](#firefox-based-browsers-3).
#### iOS
- [For iOS click here](#ios)
- Only available on Firefox-based browsers because Chromium-based browsers for the Android platform don't support installing extensions.
<br>
<br>
## Using
No more steps are required, it should start working right away.
If required you can restart your browser.
**🎉 🎊 Congratulations** 🥳 🎊
<br>
<br>
## Updating
### Extension / Addon
On [Chromium based browsers][4]
- [from webstore][1]
- sideloading from [crx/zip file](#from-crxzip-file) or [unzipped folder](#from-unzipped-folder)
On [Firefox based browsers][5]
- [from addon store][2]
- [sideloading from xpi/jar/zip file](#from-xpijarzip-file)
On [Firefox based browsers][5] for mobile
- [from addon store][2]
<br>
<br>
## Miscellaneous
### Using YouTube website as an app with an extension
#### Desktop
##### Chromium Based Browsers
0. Go to YouTube
1. Tap on menu button (`☰` / `⋯` / `⋮`)
2. Click on "Install YouTube"
3. 🎉Done!
<br>
##### Firefox Based Browsers
**⚠ Doesn't work on newer versions.**
1. Type [`about:config`](about:config) in address bar & press `Enter` key.
2. When warned `Proceed with Caution` click on `Accept the Risk and Continue`
3. Locate and click on the search bar on the page.
4. Type `browser.ssb.enable`.
5. click on `⇋` and make sure the state is set to `true`.
6. Restart the browser and Open YouTube.
7. Click on `⋮` located in the upper right corner
8. Click `Install this website as an app` or `Install this website as an app` which depends on the version you are using.
9. Done! 🎉 You should be able to see the site's app in the start menu.
You can also refer here (for pictures):
[How to Enable the Site-Specific Browser (SSB) in Firefox - Make Tech Easier](https://www.maketecheasier.com/enable-site-specific-browser-firefox)
<br>
#### Mobile
##### Firefox Based Browsers
0. Install the extension
1. Go to YouTube
2. Tap on the three dots `⋮` located at the bottom right corner of the screen.
3. Turn on "Desktop Site".
4. Refresh the page.
5. Wait for it to completely load.
6. Tap again on the three dots `⋮` located at the bottom right corner of the screen.
7. You will see an option as `Install`. Tap on it.
8. Tap - `Add` when asked `Add to Home screen?`
9. Enjoy the YT site as a Firefox nightly app !!
<br>
##### [Chromium Based Browsers][4]
But still - here are the steps:
1. Go to YouTube
2. Tap on the three dots `⋮` located at the bottom right corner of the screen.
3. Turn on "Desktop Site".
4. Refresh the page and wait for it to completely load.
5. Tap again on the three dots `⋮` located at the bottom right corner of the screen.
6. You will see an option as `Install`. Tap on it.
7. When asked `Install App` tap on `Yes`
8. Tap - `Add` when asked `Add to Home screen?`
9. Enjoy the YT site as an app !!
<br>
<br>
<!-- links -->
[1]: https://chrome.google.com/webstore/detail/return-youtube-dislike/gebbhagfogifgggkldgodflihgfeippi
[2]: https://addons.mozilla.org/en-US/firefox/addon/return-youtube-dislikes/
[3]: https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js
[4]: https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium
[5]: https://en.wikipedia.org/wiki/Category:Web_browsers_based_on_Firefox
[6]: chrome://extensions
+184
View File
@@ -0,0 +1,184 @@
Hướng dẫn Khắc phục Sự cố
**Mục lục**
- [Extension](#extension)
- [Basic checks](#basic-checks)
- [Check API status](#check-api-status)
- [Install certificates](#install-certificates)
- [Check for logs in the console](#check-for-logs-in-the-console)
- [In Chromium Based browsers](#in-chromium-based-browsers)
- [In Firefox Based Browsers](#in-firefox-based-browsers)
- [Check for conflicting extensions](#check-for-conflicting-extensions)
- [Known conflicts](#known-conflicts)
- [iOS app](#ios-app)
- [YouTube Vanced app](#youtube-vanced--app)
- [Contact in Discord Server](#contact-in-discord-server)
- [Useful Links](#useful-links)
<br>
## Extension
(for Windows & Macs)
### Basic checks
1. Make sure you have the latest version of the extension installed. ([Click Here to check](https://chrome.google.com/webstore/detail/return-youtube-dislike/gebbhagfogifgggkldgodflihgfeippi#:~:text=Report%20abuse-,Version,-2.0.0.3))
2. Close all the tabs & restart your browser
3. Reinstall the extension.
4. [Check API status](#check-api-status)
5. [Check Cloudflare status](https://www.cloudflarestatus.com/)
6. [If you are on Windows 7 read this](#install-certificates)
<br>
### Check API status
If the basic checks didn't resolve anything
[See if you get any response from this link (click here)](https://returnyoutubedislikeapi.com/votes?videoId=QOFEgexls14)
- If you **don't see something like** this, then the **API is down** and **everything is fine on your side**.
`{"id":"QOFEgexls14","dateCreated":"2021-12-28T02:53:20.995329Z","likes":2968,"dislikes":204,"rating":4.725047080979285,"viewCount":29157,"deleted":false}`
- If you see some responses but not in the above format (with likes and dislikes) then probably you are being rate-limited. It is done to prevent bot attacks and database vandalization. It depends on IP (its hash - which is never stored in non-volatile storage) for its countermeasures. If many people are accessing the server from the same IP (as in the case of public/institutional Wi-Fi) then it's possible that the IP is being rate-limited. If that's the case, there's no way for us to differentiate you from a bot/attacker.
**If you see "Certificate error" and [if you are on Windows 7 (or earlier) read this](#install-certificates)**
<br><br>
### Install certificates
**Applies for Windows 7 (and earlier) only**
and only for [Chromium Based Browsers][1]
- [Chromium-based browsers][1] don't have their own certificate manager.
- They use Windows' certificates manager.
- Microsoft has officially dropped the support for Windows 7
You will have to install the latest certificates for that.
You can follow this guide:
[Fix error NET::ERR CERT DATE INVALID - Your connection is not private - Windows 7 - 2021](https://youtu.be/JYZLxP2Z8G4)
If you don't want to install the certificate from Google Drive
- Here is the official link to the certificate [**x1.i.lencr.org**](http://x1.i.lencr.org/).
- **You will have to close all the tabs** before downloading this certificate.
**The thumbprint of real certificate is `cabd2a79a1076a31f21d253635cb039d4329a5e8`**
**To make sure that you have installed the correct certificate, you should consider checking if the thumbprints match.** To do this you can follow this guide: [How to check a certificate's thumbprint](https://knowledge.digicert.com/solution/SO9840.html)
<br>
### Check for logs in the console
#### [In Chromium Based browsers][1]
1. In Developer tools, go to [`console` panel](https://developer.chrome.com/docs/devtools/open/#console).
- For Windows press `Ctrl` + `Shift` + `J` all at once
- For Mac press `Cmd` + `Option` + `J` all at once
2. Find `filter` box in the newly appeared window.
3. Type `Return`.
4. Check the [Check API Status](#check-api-status) and see if you get similar responses.
5. If you see any errors in red [please contact us][4] and report them in our [discord server][3]
<!-- If ever needed
- For Android refer to this article: [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/) -->
<br>
#### [In Firefox Based Browsers][2]
1. Open Browser Console
- For standard keyboard layout press `Ctrl` + `Shift` + `K` all at once
- For Mac keyboard layout press `Cmd` + `Option` + `K` all at once
- For Android refer to this article: [Remotely debugging Firefox <36 for Android](https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Firefox_for_Android)
2. Find `Filter Output` box in the newly appeared window.
3. Type `Return`.
4. If you see any errors in red [please contact us][4] and report them in our [discord server][3]
<br>
### Check for conflicting extensions
Some privacy and/or security-focused extensions such as ad- or script-blockers, as well as YouTube customization plugins might prevent the extension from working correctly.
Try to disable all other extensions and test whether the extension works.
If it does, find the extension(s) preventing RYD from working correctly and re-configure them in a way that'd stop them from interfering.
<br>
### Known conflicts
> ### scriptSafe
>
> **Solution:** Trust `returnyoutubedislikeapi.com` manually
>
> ![trust returnyoutubedislikeapi.com manually](https://cdn.discordapp.com/attachments/821116437720334397/929814357708247060/unknown.png)
<br>
> ### uMatrix
>
> **Solution:** Allow XHR for `returnyoutubedislikeapi.com` manually
>
> ![Allow XHR for `returnyoutubedislikeapi.com` manually](https://media.discordapp.net/attachments/821116437720334397/929813724238336141/unknown.png)
<br>
<br>
## iOS app
Coming soon. Please have patience.
<br>
<br>
## YouTube Vanced app
Coming soon. Please have patience.
<br>
<br>
## Contact in Discord Server
**Only if nothing mentioned above helped and you still have a problem.**
[Discord server link: https://discord.gg/mYnESY4Md5][3]
0. Join the discord server if you haven't already
1. Go to the #Bugs-and-problems channel
2. There, thoroughly describe:
- your problem
- what you have tried and what didn't work
- results of the troubleshooting steps
<!-- {
"update_frequency" : "low"
} -->
<br>
<br>
## Useful Links
[List of Chromium Based Browsers][1]
[List of Firefox Based Browsers][2]
[Return-YouTube-Dislike Discord Server][3]
<!-- links -->
[1]: https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium
[2]: https://en.wikipedia.org/wiki/Category:Web_browsers_based_on_Firefox
[3]: https://discord.gg/mYnESY4Md5
[4]: #contact-in-discord-server
+7
View File
@@ -0,0 +1,7 @@
The only data collected from users is their likes and dislikes made while the extension is installed.
No personal info, account name or watch history is collected or saved.
Users are identified by a random user ID, which is not directly linked to any of their accounts, the only purpose of this user ID is to make voting process possible.
None of the saved data is shared with any third parties.
+7
View File
@@ -0,0 +1,7 @@
Dữ liệu duy nhất được thu nhập: số lượt đánh giá "thích" và "không thích" trong thời gian tiện ích mở rộng này được cài.
Dữ liệu không được thu thập cũng như không lưu trữ: thông tin cá nhân, tên tài khoản, lịch sử xem vi-đê-ô.
Người dùng được gán cho một ID người dùng ngẫu nhiên. ID người dùng này không có bất kì mối liên hệ nào tới những tài khoản của người dùng. Mục đích duy nhất của việc dùng ID người dùng là để thực hiện chức năng đánh giá vi-đê-ô.
Dữ liệu được lưu trữ không được chia sẻ với bất kì bên thứ ba nào.
+1 -1
View File
@@ -1,4 +1,4 @@
Read this in other languages: [Français](SECURITY-FAQfr.md)
Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Security
+29
View File
@@ -0,0 +1,29 @@
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
# Təhlükəsizlik
### Baxış tarixçəmi izləyirsiniz?
Xeyr. Artırmanın kodu ictimaiyyətə açıqdır və siz bunu özünüz görə bilərsiniz. Göndərilən yeganə məlumat videolar üçün bəyənməmə sayını almaq üçün tələb olunan video ID-dir. Göndərilən başqa əlavə başlıq yoxdur. Rabitə təbəqəsi vasitəsilə ictimai IP-niz serverə və sorğunun edildiyi vaxta məruz qalacaq. Bununla belə, bunların heç biri sizi heç bir şəkildə unikal şəkildə müəyyənləşdirmir. Sıfır etibar mühitini fərz etsək, əldə edə biləcəyimiz ən yaxşısı dinamik IP-dir. Bu İP bu gün sənin, sabah qonşunun ola bilər. Əgər həqiqətən IP-nizin izlənilməsindən narahatsınızsa, yəqin ki, artıq VPN-dən istifadə edirsiniz.
### Videonu bəyənməsəm, məni unikal şəkildə tanıya bilərsiniz??
Bəli. Videonu bəyənmədiyiniz zaman biz sizin üçün Google hesabınızla əlaqəli olmayan təsadüfi yaradılmış unikal ID yaradırıq. Bu, botlardan istifadənin qarşısını almaq üçün edilir. Bununla belə, bu təsadüfi identifikatoru sizə və ya şəxsi YouTube hesabınıza bağlamağın heç bir yolu yoxdur.
### Sizdə dəqiq olaraq hansı məlumat var??
Sadəcə video identifikatoru. Şərhləriniz, istifadəçi adınız, videonu kiminlə paylaşdığınız, heç bir əlavə metadata deyil. heç nə. Sadəcə video identifikatoru.
### Mənim IP ünvanım necə saxlanılır?
Backend yalnız müvəqqəti yaddaşda (RAM) haşiyələnməmiş IP ünvanlarını saxlayır. Bu ünvanlar sabit diskdə saxlanmır və buna görə də qeyd olunmur. Biz IP ünvanlarını hash edirik və əvəzinə onlar saxlanılır. Bu, verilənlər bazası vandalizminin qarşısını almaq üçün edilir.
### OAuth vasitəsilə YouTube hesabıma daxil olmaq haqqında bəzi mübahisələr eşitdim!
Bu xüsusiyyət istəyə bağlı olacaq və çox üstünlük təşkil edəcək. Əgər siz YouTube yaradıcısısınızsa və bəyənmədiyiniz statistikanı bizimlə bölüşmək istəyirsinizsə, bunu edə bilərsiniz. [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) Qurulma şəkli, əslində çox təhlükəsizdir. İstənilən vaxt hesabınıza girişi ləğv edə və bizə çox xüsusi icazələr verə bilərsiniz. Biz heç bir lazımsız icazə istəməyəcəyik. Biz yalnız video statistikanıza baxmaq üçün icazə istəyəcəyik.
### Bu sayda bəyənməmələrə necə etibar edə bilərəm??
Biz bot hücumlarının qarşısını almaq üçün tədbirlər gördük və anti-bot sisteminin effektivliyini artırmaq üçün işi davam etdirəcəyik: bu, bəyənməmələrin sayını real rəqəmin yaxşı nümayəndəsi kimi saxlamağa kömək edəcək. Əlbəttə ki, bu, heç vaxt 100% dəqiq olmayacaq, ona görə də hesaba etibar edib etməmək sizə bağlıdır.
### Niyə backend kodunu paylaşmırsınız?
Biz bunu nə vaxtsa paylaşacağıq - lakin hazırda paylaşmaq üçün həqiqətən heç bir səbəb yoxdur. Bu, yanlış təhlükəsizlik hissi verə bilər - çünki sıfır inamlı sistemdə biz bir versiyanı ifşa edə, digərini yerləşdirə bilərik. Kodu gizli saxlamağın bir çox səbəbi var, xüsusən də spamla necə mübarizə aparırıq. Spam emalı kodunu ört-basdır etmək/gizlətmək olduqca standart təcrübədir.
+32
View File
@@ -0,0 +1,32 @@
Прочетете това на други езици: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md)
# Сигурност
### Проследявате ли историята на гледанията ми?
Не. Кода на разширението е публичен и може да го видите сами. Единствената информация, която се изпраща, е идентификаторът на видеото, който е необходим, за да вземе броя на дизлайковете за видеата. Няма допълнителни заглавия, изпращани. Чрез комуникационния слой вашите обществени IP адреси ще бъдат изложени на сървъра, както и времето, в което беше направен заявката. Обаче нито едно от тези не ви идентифицира уникално по никакъв начин. Предполагайки среда с нулево доверие, най-доброто, което можем да получим, е динамичен IP адрес. Който днес е вашият, утре е на вашия съсед. Ако наистина се притеснявате, че вашият IP адрес бива проследен, вероятно вече използвате VPN.
### Можете ли да ме идентифицирате уникално, ако дизлайкна?
Да. Когато дизлайкнете видео, създаваме случайно генериран уникален идентификатор за вас, който не е свързан с вашия Google акаунт. Това се прави, за да предотвратим бот атаки. Но няма начин този случаен идентификатор да бъде свързан с вас или с вашия личен акаунт на YouTube.
### Какви точно информация имате?
Само идентификатора на видеото. Нито вашите коментари, нито потребителското ви име, нито на кого сте споделили видеото, нито допълнителни метаданни. Нищо. Само идентификатора на видеото.
### Как се съхранява моят IP адрес?
Бекендът държи незакодирани IP адреси само в изменяемата памет (RAM). Те не се съхраняват на твърд диск и следователно не се записват. Ние кодираме IP адресите, и това се съхранява. Това се прави, за да се предотврати вандализмът върху базата данни.
### Чух за OAuth и достъп до акаунта ми на YouTube!
Тази функция ще бъде по избор и изключително от ваш избор. Ако сте създател на YouTube и искате да споделите статистиката за дизлайковете с нас, можете. Структурата на [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) е наистина много сигурна. Всеки път можете да отмените достъпа до акаунта си и да дадете много конкретни разрешения на нас. Няма да поискаме никакви разрешения, които не са необходими. Ще поискаме само разрешения за преглед на статистиката на вашите видеа.
### Как мога да се доверя на този брой дизлайкове?
Въведохме мерки за предотвратяване на атаки с ботове и ще продължим да работим по подобряване на ефективността на системата за предотвратяване на ботове: това ще ни помогне да поддържаме броя на дизлайковете като добър представител на реалния брой. Разбира се, той никога няма да бъде 100% точен, така че решението дали да вярвате на броя или не, зависи от вас.
### Защо не споделяте кода на бекенда?
Ще го споделим на някакъв етап - но наистина няма реална причина да го споделяме в момента. Това създава лъжливо чувство за сигурност - защото в среда с нулево доверие можем съвсем спокойно да разкрием една версия, но да развием друга. Има много причини да държим кода скрит, специално начина, по който се борим срещу спама. Скриването/затъмняването на кода за борба със спама е стандартна практика.
+31
View File
@@ -0,0 +1,31 @@
阅读其他语言版本:[русский](SECURITY-FAQru.md)、[Nederlands](SECURITY_FAQnl.md)、[Français](SECURITY-FAQfr.md)、[Türkçe](SECURITY-FAQtr.md)、[українська](SECURITY-FAQk.md)、[Polski](SECURITY-FAQpl.md)、[Deutsch](SECURITY-FAQde.md)
# 安全
### 您是否在跟踪我的观看历史记录?
否。扩展程序的代码是公开的,您可以自己查看。发送的唯一信息是视频 ID,这是获取视频的不喜欢计数所必需的。没有发送其他标头。在通信层上,您的公共 IP 以及发出请求的时间将暴露给服务器。但是,这些都不能以任何方式唯一地识别您。假设一个零信任环境,我们能得到的最好东西就是动态 IP。今天是你的,明天是你的邻居的。如果你真的担心你的 IP 被追踪,你可能已经使用了 VPN。
### 如果我不喜欢,你能唯一地识别我吗?
是。当你不喜欢某个视频时,我们会为你创建一个随机生成的唯一 ID,该 ID 不与你的 Google 帐户绑定。这样做是为了防止机器人。但是没有办法将这个随机 ID 绑定到你或你的个人 YouTube 帐户。
### 你到底有什么信息?
只有视频 ID。不是你的评论,不是你的用户名,不是你与谁分享了视频,也不是任何其他元数据。什么都没有。只有视频 ID。
### 我的 IP 如何存储?
后端仅将未散列的 IP 地址保存在易失性存储器 (RAM) 中。这些地址不存储在硬盘上,因此不会被记录。我们对 IP 地址进行散列,然后将其存储起来。这样做是为了防止数据库遭到破坏。
### 我听到一些关于 OAuth 和访问我的 YouTube 帐户的讨论!
此功能是可选的,并且非常可选择加入。如果您是 YouTube 创作者,并且想与我们分享您的不喜欢统计数据,您可以这样做。 [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) 的结构方式实际上非常安全。您可以随时撤销对您帐户的访问权限,并可以向我们授予非常具体的权限。我们不会要求任何不必要的权限。我们只会要求查看您的视频统计数据的权限。
### 我如何信任这个不喜欢计数?
我们已经实施了防止机器人攻击的措施,并将继续努力提高机器人预防系统的有效性:这将帮助我们将不喜欢计数保持为实际计数的良好代表。当然,它永远不会 100% 准确,因此由您决定是否信任计数。
### 您为什么不共享后端代码?
我们会在某个时候共享它 - 但现在真的没有真正的理由共享它。它给人一种虚假的安全感 - 因为在零信任系统中,我们也可以披露一个版本但部署另一个版本。隐藏代码的原因有很多,特别是我们如何对抗垃圾邮件。隐藏/混淆垃圾邮件处理代码是一种相当标准的做法。
+32
View File
@@ -0,0 +1,32 @@
Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md)
# Sicherheit
### Verfolgen Sie meine Anzeigehistorie?
Nein. Der Code der Erweiterung ist öffentlich und Sie können ihn selbst einsehen. Die einzige übermittelte Information ist die Video-ID, die benötigt wird, um die Anzahl der Dislikes für die Videos abzurufen. Es werden keine zusätzlichen Header gesendet. Über die Kommunikationsschicht wird Ihre öffentliche IP-Adresse an den Server übermittelt, sowie die Zeit, zu der die Anfrage gestellt wurde. Keine dieser Informationen identifiziert Sie auf eindeutige Weise. Unter der Annahme einer Zero-Trust-Umgebung ist das Beste, was wir bekommen können, eine dynamische IP. Die heute Ihnen gehört, gehört morgen Ihrem Nachbarn. Wenn Sie sich wirklich Sorgen machen, dass Ihre IP zurückverfolgt wird, verwenden Sie wahrscheinlich bereits ein VPN.
### Können Sie mich eindeutig identifizieren, wenn ich etwas dislike?
Ja. Wenn Sie ein Video nicht mögen, erstellen wir eine zufällig generierte eindeutige ID für Sie, die nicht mit Ihrem Google-Konto verknüpft ist. Dies geschieht, um das Botting zu verhindern. Es gibt jedoch keine Möglichkeit, diese zufällige ID mit Ihnen oder Ihrem persönlichen YouTube-Konto zu verknüpfen.
### Welche Informationen haben Sie genau?
Nur die Video-ID. Nicht Ihre Kommentare, nicht Ihren Benutzernamen, nicht mit wem Sie das Video geteilt haben, keine zusätzlichen Metadaten. Nichts. Nur die Video-ID.
### Wie wird meine IP gespeichert?
Der Backend speichert nicht gehashte IP-Adressen nur im flüchtigen Speicher (RAM). Diese Adressen werden nicht auf einer Festplatte gespeichert und daher nicht protokolliert. Wir hashen die IP-Adressen und speichern diese stattdessen. Dies geschieht, um Datenbankvandalismus zu verhindern.
### Ich habe etwas über OAuth gehört und den Zugriff auf mein YouTube-Konto!
Diese Funktion wird optional sein und sehr stark opt-in. Wenn Sie ein YouTube-Ersteller sind und Ihre Dislike-Statistiken mit uns teilen möchten, können Sie dies tun. Die Art und Weise, wie [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) strukturiert wurde, ist tatsächlich sehr sicher. Sie können den Zugriff auf Ihr Konto jederzeit widerrufen und uns sehr spezifische Berechtigungen erteilen. Wir werden nicht nach Berechtigungen fragen, die nicht erforderlich sind. Wir werden nur um Berechtigungen bitten, um Ihre Video-Statistiken anzeigen zu können.
### Wie kann ich diesem Dislike-Zähler vertrauen?
Wir haben Maßnahmen implementiert, um Bot-Angriffe zu verhindern, und werden weiterhin daran arbeiten, die Effektivität des Bot-Präventionssystems zu verbessern: Dies wird uns helfen, den Dislike-Zähler als gute Repräsentation der tatsächlichen Anzahl zu halten. Natürlich wird es nie zu 100% genau sein, also liegt es an Ihnen zu entscheiden, ob Sie dem Zähler vertrauen oder nicht.
### Warum teilen Sie den Backend-Code nicht?
Wir werden ihn irgendwann teilen - aber es gibt wirklich keinen echten Grund, ihn jetzt zu teilen. Es vermittelt ein falsches Sicherheitsgefühl - denn in einem Zero-Trust-System könnten wir genauso gut eine Version offenlegen, aber eine andere bereitstellen. Es gibt viele Gründe, den Code verborgen zu halten, insbesondere wie wir gegen Spam kämpfen. Das Verbergen/Verfremden des Spam-Behandlungscodes ist eine ziemlich standardmäßige Praxis.
+2 -2
View File
@@ -1,8 +1,8 @@
Lisez ceci dans d'autres langues : [English](SECURITY-FAQ.md)
Lisez ceci dans d'autres langues : [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Sécurité
### Est-ce que vous traquer l'historique des vidéos que je visionne ?
### Est-ce que vous traquez l'historique des vidéos que je visionne ?
Non. Le code de l'extension est public et vous pouvez le voir par vous-même. La seule information envoyée est l'ID de la vidéo, qui est nécessaire pour récupérer le nombre de dislikes des vidéos. Aucun en-tête (headers) supplémentaire n'est envoyé. Sur la [couche de communication](https://fr.wikipedia.org/wiki/Mod%C3%A8le_OSI#Caract%C3%A9risation_r%C3%A9sum%C3%A9e_des_couches), votre adresse IP publique sera exposée au serveur, ainsi que l'heure à laquelle la demande a été faite. Toutefois, aucun de ces éléments ne permet de vous identifier de manière unique. Dans un environnement où vous ne pouvez avoir confiance en personne (zero-trust environment), le mieux que l'on puisse obtenir est une IP dynamique. Qui, aujourd'hui est la vôtre, demain est celle de votre voisin. Si vous êtes vraiment inquiet que votre IP soit tracée, vous utilisez probablement déjà un VPN.
+31
View File
@@ -0,0 +1,31 @@
Baca ini dibahasa lain: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
# Keamanan
### Apakah kalian melacak riwayat video yang saya tonton?
Tidak, Kode extension bersifat publik dan kamu dapat melihatnya sendiri. Informasi yang kami ambil hanya id dari video, yang diperlukan untuk pengambilan jumlah dislike. Tidak ada tambahan data headers lainnya. Pada communication layer, IP Publik kamu akan terekspos ke server, bersamaan dengan waktu ketika requestnya dibuat. Meskipun begitu, data ini tidaklah bersifat unik atau mengidentifikasikan kamu dalam bentuk apapun. Asumsikan environment yang tidak terpercaya, hal terbaik yang dapat kami ambil adalah IP dinamis. Yang bisa saja hari ini adalah milikmu, besok milik tetanggamu. Jika kamu merasa khawati jika IP-mu dilacak, kamu mungkin telah menggunakan VPN.
### Bisakah kalian mengidentifikasi saya ketika saya dislike suatu video?
Iya. Ketika kamu dislike suatu video, kami membuat id yang digenerate secara acak untukmu yang tidak ada hubungannya dengan akun Google. Ini dilakukan untuk menghindari bot. Tapi tidak mungkin untuk mencari kamu atau akun Youtube-mu menggunakan id acak ini.
### Informasi apa saja yang kalian punya?
Hanya id dari video. Bukan komentarmu, bukan usernamemu, bukan siapa saja orang yang pernah kamu kirimkan video, bukan metadata tambahan. Tidak ada. Hanya id dari video.
### Bagaimana IP saya disimpan?
Backend menyimpan alamat IP yang tidak di hash hanya di dalam RAM. Alamat ini tidak disimpan di hard drive, sehingga tidak di log. Kami melakukan hash terhadap alamat IP dan menyimpan yang itu. Ini dilakukan untuk mencegah perusakan database.
### Saya pernah mendengar diskusi mengenai OAuth, dan akses ke akun Youtube saya!
Fitur ini bersifat opsional, dan banyak yang berpartisipasi. Jika kamu merupakan seorang Youtuber, dan mau membagikan data dislikenya kepada kami, kamu bisa melakukannya. Dengan cara [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) yang terstruktur, dan sangat aman. Kamu dapat menghapus akses ke akunmu kapanpun, dan bisa menentukan data mana yang mau dibagikan dan tidak dibagikan secara spesifik. Kami tidak akan meminta izin apapun yang bersifat tidak wajib. Kami hanya akan meminta izin untuk melihat data video kamu.
### Bagaimana bisa saya percaya jumlah dislike ini?
Kami telah melakukan tidakan pencegahan dari penyerangan bot dan akan terus mengimprovisasi keefektifan sistem dari pecegahan bot tersebut: ini akan membantu kami menjaga jumlah dislike agar tetap bisa menjadi representasi dari jumlah yang asli. Tentu jumlahnya tidak akan akurat 100% jadi pada akhirnya itu semua terserah padamu untuk percaya atau tidak dengan jumlah dislikenya.
### Mengapa kamu tidak memberikan kode backend?
Kami akan memberikannya nanti pada satu waktu - tapi tidak ada alasan khusus untuk memberikanny sekarang. Itu memberikan rasa keamanan yang salah - karena pada sistem yang tidak terpercaya, kami bisa saja memberikan versi yang berdeda dengan yang kami deploy. Ada beberapa alsan lain untuk tetap menyembunyikan kodenya, terutama untuk memerangi spam. Menyembunyikan/menyamarkan kode penanganan spam adalah praktik yang cukup standar dilakukan.
+41
View File
@@ -0,0 +1,41 @@
Lees dit in andere talen: [English](SECURITY_FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Veiligheid
### Houd je mijn kijkgeschiedenis bij?
Nee. De code van de extensie is openbaar en u kunt deze zelf zien. De enige informatie die wordt verzonden, is de video-ID, die
nodig is om het aantal dislikes voor de video's op te halen. Er worden geen extra headers verzonden. Via de communicatielaag
wordt uw openbare IP-adres zichtbaar voor de server, evenals het tijdstip waarop het verzoek is gedaan. Geen van deze
identificeert u echter op enigerlei wijze uniek. Uitgaande van een zero-trust-omgeving, is het beste wat we kunnen krijgen een
dynamisch IP-adres. Wat vandaag van jou is, morgen van je buurman. Als u zich echt zorgen maakt dat uw IP-adres wordt
getraceerd, gebruikt u waarschijnlijk al een VPN.
### Kun je me uniek identificeren als ik een hekel heb?
Ja. Als je een video niet leuk vindt, maken we een willekeurig gegenereerde unieke ID voor je die niet is gekoppeld aan je
Google-account. Dit wordt gedaan om botting te voorkomen. Maar er is geen manier om deze willekeurige id aan jou of je
persoonlijke YouTube-account te koppelen.
### Welke informatie heb je precies?
Alleen de video-ID. Niet je opmerkingen, niet je gebruikersnaam, niet met wie je de video hebt gedeeld, geen aanvullende
metadata. Niks. Alleen de video-ID.
### Hoe wordt mijn IP opgeslagen?
De backend bewaart niet-gehashte IP-adressen alleen in vluchtig geheugen (RAM). Deze adressen worden niet opgeslagen op een
harde schijf en worden daarom niet gelogd. We hashen de IP-adressen en dat wordt in plaats daarvan opgeslagen. Dit wordt gedaan
om databasevandalisme te voorkomen.
### Ik hoorde wat discussie over OAuth en toegang tot mijn YouTube-account!
Deze functie zal optioneel zijn, en zeer veel opt-in. Als je een YouTube-creator bent en je wilt je afkeerstatistieken met ons delen, dan kan dat. De weg [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) gestructureerd was, is het eigenlijk heel veilig. U kunt op elk moment de toegang tot uw account intrekken en ons zeer specifieke machtigingen geven. We zullen geen toestemming vragen die niet vereist zijn. We vragen alleen toestemming om je videostatistieken te bekijken.
### Hoe kan ik deze afkeertelling vertrouwen?
We hebben maatregelen genomen om botaanvallen te voorkomen en zullen blijven werken aan het verbeteren van de effectiviteit van het botpreventiesysteem: dit zal ons helpen het aantal afkeer als een goede vertegenwoordiger van het werkelijke aantal te houden. Natuurlijk zal het nooit 100% nauwkeurig zijn, dus het is aan jou om te beslissen of je de telling vertrouwt of niet.
### Waarom deel je de backend-code niet?
We zullen het op een gegeven moment delen - maar er is echt geen echte reden om het nu te delen. Het geeft een vals gevoel van veiligheid - omdat we in een zero-trust-systeem net zo goed de ene versie kunnen onthullen, maar een andere kunnen implementeren. Er zijn tal van redenen om de code verborgen te houden, met name hoe we spam bestrijden. Het verbergen/verduisteren van de spamverwerkingscode is een vrij standaardpraktijk.
+31
View File
@@ -0,0 +1,31 @@
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Security
### Czy śledzicie moją historię wyświetleń?
Nie. Kod rozszerzenia jest publiczny i można samemu to zobaczyć. Jedyne informacje, które są wysyłane, to ID filmu, które jest wymagane do pobrania liczby łapek w dół dla filmów. Nie są wysyłane żadne dodatkowe nagłówki. W warstwie sieciowej, Twój adres IP będzie jawny dla serwera wraz z czasem wykonania żądania. Jednakże, żadne z tych danych nie identyfikują Ciebie jednoznacznie w żaden sposób. Zakładając środowisko zerowego zaufania, najczulsze dane, jakie możemy otrzymać, jest dynamiczny adres IP, który dzisiaj jest Twój, a jutro Twojego sąsiada. Jeżeli boisz się śledzenia poprzez adres IP, pewnie już korzystasz z VPN.
### Czy możecie mnie jednoznacznie zidentyfikować, jeżeli zostawię łapkę w dół?
Tak. Kiedy zostawiasz łapkę w dół, tworzymy losowo generowane ID dla Ciebie, które nie jest związane z Twoim kontem Google. Powodem takiego rozwiązania jest zapobieganie botowaniu. Mimo to, nie ma sposobu powiązania tego losowego ID z Tobą lub Twoim osobistym kontem Google.
### Jakie informacje wy macie, konkretnie?
Tylko ID filmu. Komentarze - nie. Nazwa użytkownika - nie. Osoby, którym udostępniłeś film - nie. Jakiekolwiek dodatkowe metadane - nie. Nic. Tylko ID filmu.
### Jak mój adres IP jest przechowywany?
Backend trzyma niehashowane adresy IP tylko w pamięci zmiennej (RAM). Te adresy nie są przechowywane na dysku twardym, przez co nie są rejestrowane. Zamiast tego przechowujemy zhashowany adres IP. Jest to zrobione po to aby zapobiec wandalizmom.
### Słyszałem jakąś dyskusję o OAuth, i dostępie do mojego konta YouTube.
Ta funkcjonalność będzie opcjonalna i z całą pewnością wymagała ręcznego dołączenia. Jeżeli jesteś twórcą na YouTube i chcesz podzielić się z nami swoimi statystykami, to możesz. Sposób w jaki [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) został ustrukturyzowany jest właściwie bardzo bezpieczny. Możesz wycofać dostęp do konta w każdej chwili i dać nam specyficzne uprawnienia. Nie będziemy prosić o żadne uprawnienia, które nie są wymagane. Poprosimy tylko o możliwość wyświetlenia statystyk filmów.
### Jak bardzo mogę ufać licznikowi łapek w dół?
Zaimplementowaliśmy środki zapobiegające atakom botów i będziemy kontynuować pracę nad systemem zapobiegającym botom: to pomoże nam utrzymać licznik łapek w dół jako dobrą reprezentację prawdziwej wartości. Oczywiście, wartość ta nigdy nie będzie w 100% dokładna, więc to czy zaufasz tej liczbie zależy tylko od Ciebie.
### Dlaczego nie udostępnicie kodu backendu?
Kiedyś go udostępnimy - ale nie ma za bardzo sensu robić to teraz. Wprowadziłoby to fałszywe poczucie bezpieczeństwa - bo w systemie zerowego zaufania równie dobrze moglibyśmy przedstawić jeden system, a uruchomić inny. Jest dużo powodów do ukrywania kodu, specyficznie do walki ze spamem. Ukrywanie/Obfuskacja kodu jest dość standardową praktyką.
+31
View File
@@ -0,0 +1,31 @@
Leia isso em outros idiomas: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md)
# Segurança
### Are you tracking my viewing history?
No. The extension's code is public and you can see it for yourself. The only information being sent is the video ID, which is required to fetch the dislike count for the videos. There are no additional headers being sent. Over the communication layer, your public IP will be exposed to the server, as well as the time when the request was made. However, none of these are uniquely identifying you in any way. Assuming a zero-trust environment, the best we could get is a dynamic IP. Which, today is yours, tomorrow is your neighbor's. If you're really worried about your IP being traced, you probably already use a VPN.
### Can you uniquely identify me if I dislike?
Yes. When you dislike a video, we create a randomly generated unique ID for you that is not tied to your Google account. This is done to prevent botting. But there is no way to tie this random Id to you or your personal YouTube account.
### Quais as informações que você tem, exatamente?
Somente seu video ID. Não temos seus comentarios, seu nome de usuario, ou qualquer outro Not your comments, not your username, not who you've shared the video with, not any additional metadata. NADA. Apenas seu Video ID.
### How is my IP stored?
The backend keeps unhashed IP addresses in volatile memory (RAM) only. These addresses aren't stored on a hard drive, and therefore aren't logged. We hash the IP addresses, and that's stored instead. This is done to prevent database vandalism.
### I heard some discussion over OAuth, and access to my YouTube account!
This feature will be optional, and very much opt-in. If you are a YouTube creator, and would like to share your dislike stats with us, you can. The way [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) was structured, it's actually very secure. You can revoke access to your account at any time, and can give very specific permissions to us. We will not ask for any permissions that aren't required. We'll only ask for permissions to view your video stats.
### How can I trust this dislike count?
We have implemented measures to prevent bot attacks and are gonna continue to work on improving the effectiveness of the bot prevention system: this will help us keep the dislike count as a good representative of the actual count. Of course it will never be 100% accurate so it's up to you to decide whether you trust the count or not.
### Why don't you share the backend code?
We will share it at some point - but there's really no real reason to share it right now. It gives a false sense of security - because in a zero-trust system, we could just as well disclose one version but deploy another. There are plenty of reasons to keep the code hidden, specifically, how we battle spam. Hiding/Obfuscating the spam handling code is a fairly standard practice.
+1 -1
View File
@@ -1,4 +1,4 @@
Прочитать на других языках: [English](SECURITY-FAQ.md)
Прочитать на других языках: [English](SECURITY-FAQ.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Безопасность
+31
View File
@@ -0,0 +1,31 @@
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Güvenlik
### İzleme geçmişimi takip ediyor musunuz?
Hayır. Uzantının kodu herkese açıktır ve kendiniz görebilirsiniz. Gönderilen tek bilgi, videolar için dislike sayısını almak için gereken video kimliğidir. Gönderilen başka bir ek header yoktur. İletişim katmanı üzerinden, genel IP'niz sunucuya ve isteğin yapıldığı zamana maruz kalacaktır. Ancak, bunların hiçbiri sizi hiçbir şekilde benzersiz bir şekilde tanımlamıyor. Sıfır güven ortamını varsayarsak, elde edebileceğimizin en iyisi dinamik bir IP'dir. Ki, bu IP bugün sizin, yarın komşunuzun olabilir. IP'nizin izlenmesinden gerçekten endişeleniyorsanız, muhtemelen zaten bir VPN kullanıyorsunuzdur.
### Bir videoya dislike atarsam, beni benzersiz bir şekilde tanımlayabilir misiniz?
Evet. Bir videoya dislike attığınızda, sizin için Google hesabınızla bağlantılı olmayan rastgele oluşturulmuş benzersiz bir kimlik oluştururuz. Bu, bot kullanılmasını önlemek için yapılır. Ancak bu rastgele kimliği, size veya kişisel YouTube hesabınıza bağlamanın bir yolu yoktur.
### Tam olarak hangi bilgilere sahipsiniz, gerçekten?
Sadece video ID'si. Yorumlarınızı değil, kullanıcı adınızı değil, videoyu kiminle paylaştığınız değil, ek meta verilerinden hiçbiri değil. Hiç bir şey. Sadece video ID'si.
### IP adresim nasıl saklanıyor?
Backend, karma olmayan IP adreslerini yalnızca geçici bellekte (RAM'de) tutar. Bu adresler, bir sabit sürücüde depolanmaz ve bu nedenle günlüğe kaydedilmez. IP adreslerini hash ederiz ve bunun yerine depolanır. Bu, veri tabanı vandalizmini önlemek için yapılır.
### OAuth üzerinden YouTube hesabıma erişmek konusunda bazı tartışmalar duydum!
Bu özellik isteğe bağlı olacak ve çok fazla tercih edilecek. Bir YouTube içerik üreticisiyseniz ve dislike istatistiklerinizi bizimle paylaşmak istiyorsanız, bunu yapabilirsiniz. [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) yapılandırılma şekli, aslında çok güvenlidir. Hesabınıza erişimi istediğiniz zaman iptal edebilir ve bize çok özel izinler verebilirsiniz. Gerekli olmayan herhangi bir izini istemeyeceğiz. Yalnızca video istatistiklerinizi görüntülemek için izin isteyeceğiz.
### Bu dislike sayısına nasıl güvenebilirim?
Bot saldırılarını önlemek için önlemler aldık ve bot önleme sisteminin etkinliğini arttırmak için çalışmaya devam edeceğiz: bu, dislike sayısını gerçek sayının iyi bir temsilcisi olarak tutmamıza yardımcı olacaktır. Tabii ki hiçbir zaman %100 doğru olmayacaktır, bu yüzden sayıma güvenip güvenmemek size kalmıştır.
### Neden backend kodunu paylaşmıyorsunuz?
Bir noktada paylaşacağız - ama şu anda paylaşmak için ortada gerçekten gerçek bir sebep yok. Yanlış bir güvenlik hissi verebilir - çünkü sıfır güvenli bir sistemde, bir sürümü ifşa edebilir, ancak bir başkasını devreye alabiliriz. Özellikle spam ile nasıl mücadele ettiğimiz gibi, kodu gizli tutmak için birçok neden vardır. İstenmeyen posta işleme kodunu örtmek/gizlemek oldukça standart bir uygulamadır.
+31
View File
@@ -0,0 +1,31 @@
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Безпека
### Ви відстежуєте мою історію переглядів?
Ні. Код розширення знаходиться у відкритому доступі, і ви можете ознайомитись з ним самостійно. Єдина інформація, що передається - це ID відео, який необхідний для отримання даних про кількість відміток. Жодних додаткових заголовків не передається. На комунікаційному рівні серверу буде передано вашу публічну IP-адресу, а також час, коли було зроблено запит. Однак ніщо з цього ніяк не здатно вас ідентифіквати. Припускаючи середовище з нульовою довірою, найцінніше, що ми можемо отримати це динамічний IP. Який сьогодні ваш, а завтра – вашого сусіда. Якщо ви дійсно турбуєтеся про те, що ваш IP може бути відстежений, ви, ймовірно, вже використовуєте якийсь VPN.
### Чи можете ви однозначно ідентифікувати мене, коли я залишаю відмітку «Не подобається»?
Так. Коли ви залишаєте відео позначку «Не подобається», ми створюємо для вас випадковий унікальний ID, який не прив'язаний до вашого облікового запису Google. Це робиться для уникнення атаки ботів. Але немає жодного способу зв'язати цей випадковий ID до вас або вашого особистого облікового запису YouTube.
### Якою саме інформацією ви володієте?
Лише ID відео. Ні ваших коментарів, ні вашого імені користувача, ні того, з ким ви поділилися відео, ні будь-яких інших додаткових метаданих. Нічого. Лише ID відео.
### Як зберігається моя IP-адреса?
Внутрішня частина нашого сервера зберігає нехешовані IP-адреси лише в енергозалежній пам'яті (ОЗП). Ці адреси не зберігаються на жорсткому диску і тому ніде не записані. Ми хешуємо IP-адреси, і вони зберігаються замість інших. Це зроблено для запобігання вандалізму у базі даних.
### Я чув дискусію щодо OAuth і доступу до мого облікового запису YouTube!
Ця функція буде необов'язковою та дуже необхідною. Якщо ви творець YouTube і хочете поділитися з нами кількістю ваших відміток «Не подобається», ви зможете це зробити. За структурою [OAuth](https://uk.wikipedia.org/wiki/OAuth#:~:text=%D0%B1%D0%B5%D0%B7%20%D0%BD%D0%B5%D0%BE%D0%B1%D1%85%D1%96%D0%B4%D0%BD%D0%BE%D1%81%D1%82%D1%96%20%D0%B2%D0%B2%D0%BE%D0%B4%D1%83%20%D1%96%D0%BC%D0%B5%D0%BD%D1%96%20%D0%BA%D0%BE%D1%80%D0%B8%D1%81%D1%82%D1%83%D0%B2%D0%B0%D1%87%D0%B0%20%D1%82%D0%B0%20%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8E) надбезпечний. Ви зможете відкликати доступ до свого облікового запису в будь-яку мить, і зможете дати нам обмежені дозволи. Ми не будемо вимагати жодних дозволів, крім необжідних. Ми будемо вимагати лише дозволи перегляду статистики ваших відео.
### Наскільки достовірна ця кількость відміток «Не подобається»?
Ми вжили заходів щодо запобігання атакам ботів і збираємося продовжувати працювати над підвищенням ефективності цієї сисеми надалі: це допоможе нам зберегти підрахунок відміток «Не подобається» як хороше представлення фактичної кількості. Звичайно, він ніколи не буде точним на всі 100%, тож ви самі вирішуєте, довіряти йому чи ні.
### Чому б вам не поділитися внутрішнім кодом вашого сервера?
Ми поділимося ним у якийсь момент – але зараз немає жодних причин це робити. Це дасть хибне почуття безпеки - адже в системі з нульовою довірою ми можемо з таким самим успіхом поділитися однією версією, але розгорнути іншу. Є багато причин тримати код у таємниці, зокрема, як ми боремося зі спамом. Приховування та затьмарення коду обробки спаму є доволі стандартною практикою.
+32
View File
@@ -0,0 +1,32 @@
Đọc bằng các ngôn ngữ khác: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Tiếng Việt](SECURITY-FAQvi.md)
# Tính Bảo mật
### Tiện ích mở rộng này có theo dõi lịch sử xem vi-đê-ô của tôi không?
Không. Mã nguồn của tiện ích mở rộng này được cung cấp công khai và bạn có thể kiểm tra mã nguồn nếu muốn. Thông tin duy nhất được tiện ích này gửi đi là ID của vi-đê-ô vì việc truy vấn số lượt đánh giá "không thích" cần ID của vi-đê-ô. Không thông tin nào khác được gửi đi. Ở lớp truyền tải, máy chủ sẽ biết được địa chỉ IP của bạn cũng như thời điểm mà truy vấn được tạo. Tuy nhiên, những thông tin này không thể định danh chính xác bạn. Giả sử với môi trường bất tín, chúng tôi cùng lắm chỉ có thể biết được địa chỉ IP động. Địa chỉ IP động là của bạn ngày hôm nay, nhưng sẽ là của người khác vào ngày mai. Nếu bạn lo lắng rằng địa chỉ IP của mình bị theo dõi, bạn có thể sử dụng mạng riêng ảo.
### Nhóm phát triển có thể định danh chính xác tôi không nếu như tôi có đánh giá "không thích"?
Có. Khi bạn đưa ra đánh giá "không thích" với một vi-đê-ô, chúng tội sẽ tạo ra một số định danh duy nhất ngẫu nhiên cho bạn, nhưng số định danh này không có bất kì mối liên hệ nào với tài khoản Google của bạn. Việc tạo số định danh là nhằm tránh người dùng bot. Không có bất kì cách nào để liên hệ số định danh này với bạn hay với tài khoản YouTube của bạn.
### Chính xác thì thông tin nào được thu thập?
Chỉ có ID của vi-đê-ô sẽ được thu thập. Bình luận của bạn, không. Tên người dùng của bạn, không. Thông tin của người mà bạn chia sẻ vi-đê-ô, không. Siêu dữ liệu khác, không. Không có bất kì thông tin nào khác được thu thập. Chỉ duy nhất ID của vi-đê-ô.
### Địa chỉ IP của tôi được lưu trữ ra sao?
Đầu sau chỉ giữ địa chỉ IP không được băm trong bộ nhớ khả biến (RAM). Những địa chỉ này không được lưu vào bộ nhớ bất biến (như ổ đĩa cứng) và như vậy không bị ghi chép lại. Chúng tôi băm địa chỉ IP rồi mới lưu lại những địa chỉ đã băm. Việc lưu địa chỉ đã băm là nhằm tránh việc phá hoại cơ sở dữ liệu.
### Tôi có bắt gặp một số cuộc thảo luận về OAuth cũng như về việc truy cập tài khoản YouTube của tôi!
Tính năng sắp có này là tùy chọn. Nếu bạn là nhà sáng tạo nội dung trên YouTube và bạn mong muốn chia sẽ số liệu đánh giá "không thích" với chúng tôi, chúng tôi rất hoan nghênh. Cách thức hoạt động của [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) giúp đảm bảo tính bảo mật. Bạn có thể hủy bỏ các truy cập vào tài khoản của bạn bất cứ lúc nào bạn muốn và cũng như có thể đưa ra bất kì sự cho phép cụ thể nào. Chúng tôi sẽ không đòi hỏi bất kì sự cho phép nào không cần thiết. Chúng tôi chỉ xin được cấp phép để xem số liệu về vi-đê-ô của bạn.
### Số lượt đánh giá "không thích" có đáng tin cậy không?
Chúng tôi đã và đang thực hiện nhiều biện pháp phòng chống việc tấn công từ người dùng bot và sẽ tiếp duy trì việc tăng cường tính hiệu quả của hệ thống phòng chống bot: Điều này sẽ giúp chúng tôi đảm bảo rằng số lượt đánh giá "không thích" của tiện ích phản ánh đúng số lượt đánh giá thực. Tất nhiên là không thể đảm bảo chính xác hoàn toàn, cho nên tùy thuộc vào bạn có muốn tin hay không.
### Tại sao mã nguồn của đầu sau không được chia sẻ?
Chúng tôi sẽ chia sẻ mã nguồn của đầu sau trong tương lại. Nhưng không có lí do gì thực sự cấp thiết để phải chia sẻ mã nguồn vào thời điểm hiện nay. Việc chia sẻ mã nguồn sẽ tạo ra sự an tâm hão huyền vì trong môi trường bất tín, chúng tôi có thể chia sẻ mã nguồn này nhưng lại dùng mã nguồn khác. Có nhiều lí do để giữ bí mật mã nguồn, trong đó có việc chống tương tác rác. Che giấu/Làm rối mã xử lý tương tác rác là biện pháp tiêu chuẩn.
+31
View File
@@ -0,0 +1,31 @@
اقرأ هذا بلغات أخرى: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
# الأمان
### هل تقومون بتتبع سجل المشاهدة الخاص بي؟
لا. كود الإضافة عام ويمكنك رؤيته بنفسك. المعلومات الوحيدة التي يتم إرسالها هي معرف الفيديو، وهو مطلوب لجلب عدد عدم الإعجاب للفيديوهات. لا يتم إرسال أي رؤوس إضافية. عبر طبقة الاتصال، سيتم كشف عنوان IP العام الخاص بك للخادم، وكذلك الوقت الذي تم فيه الطلب. ومع ذلك، لا يمكن لأي من هذه المعلومات تحديد هويتك بشكل فريد بأي طريقة. بافتراض بيئة عدم الثقة، أفضل ما يمكننا الحصول عليه هو عنوان IP ديناميكي. والذي، اليوم هو لك، وغدًا هو لجارك. إذا كنت قلقًا حقًا بشأن تتبع عنوان IP الخاص بك، فمن المحتمل أنك تستخدم VPN بالفعل.
### هل يمكنكم تحديد هويتي بشكل فريد إذا قمت بعدم الإعجاب؟
نعم. عندما تقوم بعدم الإعجاب بفيديو، نقوم بإنشاء معرف فريد عشوائي لك غير مرتبط بحساب Google الخاص بك. يتم ذلك لمنع التلاعب. ولكن لا توجد طريقة لربط هذا المعرف العشوائي بك أو بحساب YouTube الشخصي الخاص بك.
### ما هي المعلومات التي لديكم بالضبط؟
فقط معرف الفيديو. ليس تعليقاتك، وليس اسم المستخدم الخاص بك، وليس من شاركت الفيديو معه، وليس أي بيانات إضافية. لا شيء. فقط معرف الفيديو.
### كيف يتم تخزين عنوان IP الخاص بي؟
يحتفظ الخلفية بعناوين IP غير المشفرة في الذاكرة المتطايرة (RAM) فقط. هذه العناوين لا يتم تخزينها على القرص الصلب، وبالتالي لا يتم تسجيلها. نقوم بتشفير عناوين IP، ويتم تخزين هذا التشفير بدلاً من ذلك. يتم ذلك لمنع تخريب قاعدة البيانات.
### سمعت بعض النقاش حول OAuth والوصول إلى حساب YouTube الخاص بي!
هذه الميزة ستكون اختيارية، وبشكل كبير تعتمد على الموافقة. إذا كنت منشئ محتوى على YouTube، وترغب في مشاركة إحصائيات عدم الإعجاب الخاصة بك معنا، يمكنك ذلك. الطريقة التي تم بها هيكلة [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) آمنة جدًا في الواقع. يمكنك إلغاء الوصول إلى حسابك في أي وقت، ويمكنك منحنا أذونات محددة جدًا. لن نطلب أي أذونات غير مطلوبة. سنطلب فقط الأذونات لعرض إحصائيات الفيديو الخاصة بك.
### كيف يمكنني الوثوق بعدد عدم الإعجاب هذا؟
لقد قمنا بتنفيذ تدابير لمنع هجمات الروبوتات وسنواصل العمل على تحسين فعالية نظام منع الروبوتات: سيساعدنا ذلك في الحفاظ على عدد عدم الإعجاب كممثل جيد للعدد الفعلي. بالطبع لن يكون دقيقًا بنسبة 100% لذا الأمر متروك لك لتقرر ما إذا كنت تثق في العدد أم لا.
### لماذا لا تشاركون كود الخلفية؟
سنشاركه في وقت ما - ولكن لا يوجد سبب حقيقي لمشاركته الآن. إنه يعطي إحساسًا زائفًا بالأمان - لأنه في نظام عدم الثقة، يمكننا الكشف عن إصدار واحد ولكن نشر آخر. هناك العديد من الأسباب للحفاظ على الكود مخفيًا، تحديدًا، كيفية مكافحة البريد العشوائي. إخفاء/تشويش كود معالجة البريد العشوائي هو ممارسة قياسية إلى حد ما.
+3 -3
View File
@@ -1,4 +1,4 @@
Read this in other languages: [Français](readmefr.md)
Read this in other languages: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
**Contents**
@@ -22,8 +22,8 @@ Read this in other languages: [Français](readmefr.md)
## FAQs
- [General](https://github.com/Anarios/return-youtube-dislike/blob/main/Guides/FAQ.md)
- [Security](https://github.com/Anarios/return-youtube-dislike/blob/main/Guides/SECURITY-FAQ.md)
- [General](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQ.md)
- [Security](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md)
<!-- - [Privacy](FAQ_Privacy.md)
- [Technical](FAQ_Technical.md)
+39
View File
@@ -0,0 +1,39 @@
Read this in other languages: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md)
**İçerikler**
- [Bələdçilər](#rehberler)
- [Tez-tez verilən suallar](#sss'ler)
<!-- - [Tez-tez verilən suallar](#sss'ler)
- [Digər Siyahılar](#diğer-listeler) -->
<br>
## Bələdçilər
- [Yükləyin, quraşdırın və istifadə edin](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [Problemlərin aradan qaldırılması](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [Tez-tez verilən suallar](FAQaz.md)
- [Bugları nə vaxt və necə bildirmək olar](Guide__Bug_Reporting.md)
- [Töhfə](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTINGtr.md) -->
<!-- - [Vikini necə yeniləmək olar](/) -->
<br>
## Tez-tez verilən suallar
- [Ümumi](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQaz.md)
- [Təhlükəsizlik](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQaz.md)
<!-- - [Təhlükəsizlik](FAQ_Privacy.md)
- [Texniki](FAQ_Technical.md)
- [İstehsalçılar](FAQ_Creators.md)
<br>
## Digər Siyahılar
- [Common Problems](Common_Problems.md)
- [Repeated Questions](Repeated_Questions.md)
- [Repeated Feature requests](Repeated_Feature_requests.md)
- [Repeated Issues](Repeated_Issues.md) -->
+39
View File
@@ -0,0 +1,39 @@
阅读其他语言版本:[English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
**目录**
- [指南](#guides)
- [常见问题](#faqs)
<!-- - [常见问题](#faqs)
- [其他列表](#other-lists) -->
<br>
## 指南
- [下载、安装和使用](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [故障排除](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [常见问题](FAQ.md)
- [何时及如何报告错误](Guide__Bug_Reporting.md)
- [贡献](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
<!-- - [如何更新 wiki](/) -->
<br>
## 常见问题
- [常规](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQcn.md)
- [安全](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQcn.md)
<!-- - [隐私](FAQ_Privacy.md)
- [技术](FAQ_Technical.md)
- [创作者](FAQ_Creators.md)
<br>
## 其他列表
- [常见问题](Common_Problems.md)
- [重复的问题](Repeated_Questions.md)
- [重复的功能请求](Repeated_Feature_requests.md)
- [重复的问题](Repeated_Issues.md) -->
+1 -1
View File
@@ -1,4 +1,4 @@
Lisez ceci dans d'autres langues : [English](readme.md)
Lisez ceci dans d'autres langues : [English](readme.md), [Nederlands](readmenl.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
**Contenu**
+39
View File
@@ -0,0 +1,39 @@
Baca ini dibahasa lain: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
**Isi**
- [Petunjuk](#petunjuk)
- [Pertanyaan yang Sering Ditanyakan](#pertanyaan-yang-sering-ditanyakan)
<!-- - [FAQs](#faqs)
- [Other Lists](#other-lists) -->
<br>
## Petunjuk
- [Mengunduh, Menginstall, dan Menggunakan](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [Troubleshooting](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [FAQ](FAQ.md)
- [When & How to Report Bugs](Guide__Bug_Reporting.md)
- [Contributing](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
<!-- - [How to update wiki](/) -->
<br>
## Pertanyaan yang Sering Ditanyakan
- [Umum](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQ.md)
- [Keamanan](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md)
<!-- - [Privacy](FAQ_Privacy.md)
- [Technical](FAQ_Technical.md)
- [Creators](FAQ_Creators.md)
<br>
## Other Lists
- [Common Problems](Common_Problems.md)
- [Repeated Questions](Repeated_Questions.md)
- [Repeated Feature requests](Repeated_Feature_requests.md)
- [Repeated Issues](Repeated_Issues.md) -->
+39
View File
@@ -0,0 +1,39 @@
Read this in other languages: [English](readme.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
**Inhoud**
- [Gidsen](#gidsen)
- [FAQs](#faqs)
<!-- - [FAQs](#faqs)
- [Andere lijsten](#andere-lijsten) -->
<br>
## Gidsen
- [Downloaden, installeren en gebruiken](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [Probleemoplossen](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [FAQ](FAQ.md)
- [Wanneer en hoe bugs te melden](Guide__Bug_Reporting.md)
- [Bijdragend](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
<!-- - [Hoe de wiki te updaten](/) -->
<br>
## FAQs
- [Algemeen](https://github.com/Anarios/return-youtube-dislike/blob/main/Guides/FAQ.md)
- [Beveiliging](https://github.com/Anarios/return-youtube-dislike/blob/main/Guides/SECURITY-FAQ.md)
<!-- - [Privacy](FAQ_Privacy.md)
- [Techisch](FAQ_Technical.md)
- [Makers](FAQ_Creators.md)
<br>
## Andere lijsten
- [Veel voorkomende problemen](Common_Problems.md)
- [Herhalende vragen](Repeated_Questions.md)
- [Herhaalde functieverzoeken](Repeated_Feature_requests.md)
- [Herhaalde problemen](Repeated_Issues.md) -->
+39
View File
@@ -0,0 +1,39 @@
Leia isso em outros idiomas: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md)
**Contents**
- [Guides](#guides)
- [FAQs](#faqs)
<!-- - [FAQs](#faqs)
- [Other Lists](#other-lists) -->
<br>
## Guides
- [Downloading, Installing & Using](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [Troubleshooting](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [FAQ](FAQ.md)
- [When & How to Report Bugs](Guide__Bug_Reporting.md)
- [Contributing](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
<!-- - [How to update wiki](/) -->
<br>
## FAQs
- [General](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQ.md)
- [Security](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md)
<!-- - [Privacy](FAQ_Privacy.md)
- [Technical](FAQ_Technical.md)
- [Creators](FAQ_Creators.md)
<br>
## Other Lists
- [Common Problems](Common_Problems.md)
- [Repeated Questions](Repeated_Questions.md)
- [Repeated Feature requests](Repeated_Feature_requests.md)
- [Repeated Issues](Repeated_Issues.md) -->
+39
View File
@@ -0,0 +1,39 @@
Read this in other languages: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
**İçerikler**
- [Rehberler](#rehberler)
- [SSS'ler](#sss'ler)
<!-- - [SSS'ler](#sss'ler)
- [Diğer Listeler](#diğer-listeler) -->
<br>
## Rehberler
- [İndirme, Yükleme ve Kullanma](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
- [Sorun Giderme](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
<!-- - [SSS](FAQtr.md)
- [Hata Raporları Ne Zaman ve Nasıl Bildirilir](Guide__Bug_Reporting.md)
- [Katkı sağlama](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTINGtr.md) -->
<!-- - [Wiki nasıl güncellenir](/) -->
<br>
## SSS'ler
- [Genel](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQtr.md)
- [Güvenlik](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQtr.md)
<!-- - [Gizlilik](FAQ_Privacy.md)
- [Teknik](FAQ_Technical.md)
- [Üreticiler](FAQ_Creators.md)
<br>
## Diğer Listeler
- [Common Problems](Common_Problems.md)
- [Repeated Questions](Repeated_Questions.md)
- [Repeated Feature requests](Repeated_Feature_requests.md)
- [Repeated Issues](Repeated_Issues.md) -->
+39
View File
@@ -0,0 +1,39 @@
Đọc bằng các ngôn ngữ khác: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
**Mục lục** <!-- Contents -->
- [Hướng dẫn](#guides)
- [Hỏi-Đáp](#faqs)
<!-- - [FAQs](#faqs)
- [Nội dung Khác](#other-lists) -->
<br>
## Hướng dẫn <!-- ## Guides -->
- [Tải, Cài đặt & Sử dụng](https://github.com/Anarios/return-youtube-dislike/wiki/Tải,-Cài-đặt-&-Sử-dụng)
- [Khắc phục Sự cố](https://github.com/Anarios/return-youtube-dislike/wiki/Hướng-dẫn-Khắc-phục-Sự-cố)
<!-- - [Hỏi-Đáp](FAQvi.md)
- [Khi nào và Làm sao Báo cáo Lỗi](Guide__Bug_Reportingvi.md)
- [Đóng góp](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTINGvi.md) -->
<!-- - [Cách cập nhật wiki](/) -->
<br>
## Hỏi-Đáp <!-- ## FAQs -->
- [Tổng quát](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQvi.md)
- [Bảo mật](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQvi.md)
<!-- - [Quyền Riêng tư](FAQ_Privacyvi.md)
- [Kỹ thuật](FAQ_Technicalvi.md)
- [Nhà Sáng tạo Nội dung](FAQ_Creatorsvi.md)
<br>
## Nội dung Khác [!-- ## Other Lists --]
- [Vấn đề Thường gặp](Common_Problemsvi.md)
- [Câu hỏi Lặp đi lặp lại](Repeated_Questionsvi.md)
- [Yêu cầu tính năng Lặp đi lặp lại](Repeated_Feature_requestsvi.md)
- [Vấn đề Lặp đi lặp lại](Repeated_Issuesvi.md) -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,645 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript navigation browser-test fixture</title>
<style>
html,
body {
margin: 0;
min-height: 768px;
width: 100%;
}
[hidden] {
display: none !important;
}
#fixture-account,
#fixture-navigation,
#fixture-page {
display: block;
}
#fixture-navigation a,
#fixture-page > a {
display: inline-block;
margin: 4px;
min-height: 24px;
min-width: 96px;
}
ytd-watch-flexy,
ytd-menu-renderer,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model,
ytd-shorts,
ytd-reel-video-renderer,
ytm-reel-video-renderer,
ytm-reel-player-overlay-renderer,
reel-action-bar-view-model,
ytm-like-button-renderer {
display: block;
}
#top-row,
ytd-menu-renderer.ytd-watch-metadata > div {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 8px;
min-height: 48px;
width: 320px;
}
segmented-like-dislike-button-view-model button {
min-height: 36px;
min-width: 96px;
}
ytd-reel-video-renderer {
display: block;
min-height: 640px;
width: 420px;
}
ytd-reel-video-renderer[hidden] {
display: none;
}
ytm-reel-video-renderer[hidden] {
display: none;
}
.slim-video-action-bar-actions,
.segmented-buttons {
display: flex;
gap: 8px;
}
ytm-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
min-height: 320px;
width: 48px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
.ytSpecButtonShapeNextIcon,
.ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
display: flex;
font:
12px/18px Arial,
sans-serif;
height: 22px;
justify-content: center;
width: 48px;
}
</style>
</head>
<body>
<div id="fixture-account"><button id="avatar-btn" aria-label="Account menu">Account</button></div>
<nav id="fixture-navigation" aria-label="Fixture navigation"></nav>
<main id="fixture-page"></main>
<script>
(() => {
const firstVideoId = "__VIDEO_ID__";
const secondVideoId = "__SECOND_VIDEO_ID__";
const marker = "rydNavigationFixture=1";
const navigation = document.getElementById("fixture-navigation");
const page = document.getElementById("fixture-page");
const isMobile = location.hostname === "m.youtube.com";
let pendingDelayedRender = null;
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
const hrefFor = (pageKind, videoId) => {
if (pageKind === "shorts") return `/shorts/${videoId}?${marker}`;
if (pageKind === "watch") return `/watch?v=${videoId}&${marker}`;
return `/@FixtureChannel?${marker}`;
};
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
<a
id="${id}"
href="${hrefFor(pageKind, videoId)}"
data-fixture-page-kind="${pageKind}"
data-fixture-video-id="${videoId || ""}"
data-fixture-control-delay="${options.controlDelay || 0}"
>${label}</a>
`;
function channelMarkup() {
const decoyControls = isMobile
? `
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
<div class="segmented-buttons" data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</div>
</div>
`
: `
<div id="menu-container" style="display: none"></div>
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
`;
return `
<section data-fixture-page-kind="channel">
<h1>Fixture channel</h1>
${decoyControls}
${linkMarkup("channel-short", "Open Short", "shorts", firstVideoId, { controlDelay: 600 })}
${linkMarkup("channel-watch", "Open video", "watch", firstVideoId, { controlDelay: 200 })}
</section>
`;
}
function watchMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
<div id="menu-container" style="display: none"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy video-id="${videoId}"></ytd-watch-flexy>
<div id="top-row">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model
data-ryd-role="buttons"
data-fixture-control-video-id="${videoId}"
>
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</div>
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="watch" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function shortRendererMarkup(videoId, active) {
return `
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share">Share</button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix">Remix</button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
}
function mobileShortRendererMarkup(videoId, active) {
return `
<ytm-reel-video-renderer
data-fixture-mobile-short="${videoId}"
video-id="${videoId}"
${active ? "is-active" : "hidden"}
>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<ytm-reel-player-overlay-renderer>
<div id="like-button">
<ytm-like-button-renderer data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</ytm-like-button-renderer>
</div>
</ytm-reel-player-overlay-renderer>
</ytm-reel-video-renderer>
`;
}
function shortsMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="shorts" data-fixture-video-id="${videoId}">
${
isMobile
? `<div id="fixture-shorts-reels">
${mobileShortRendererMarkup(videoId, true)}
${mobileShortRendererMarkup(nextVideoId, false)}
</div>`
: `<ytd-shorts>
<div id="fixture-shorts-reels">
${shortRendererMarkup(videoId, true)}
${shortRendererMarkup(nextVideoId, false)}
</div>
</ytd-shorts>`
}
${linkMarkup("short-to-watch", "Open video", "watch", nextVideoId, { controlDelay: 200 })}
${linkMarkup("short-next", "Play next Short", "shorts", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="shorts" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function pageMarkup(pageKind, videoId) {
if (pageKind === "shorts") return shortsMarkup(videoId);
if (pageKind === "watch") return watchMarkup(videoId);
return channelMarkup();
}
function dispatchNavigation() {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function render(pageKind, videoId) {
page.innerHTML = pageMarkup(pageKind, videoId);
navigation.innerHTML =
pageKind === "channel"
? ""
: linkMarkup("return-to-channel", "Return to channel", "channel", "", { controlDelay: 20 });
}
function navigate(pageKind, videoId, { controlDelay = 0, dispatchEvent = true } = {}) {
history.pushState({}, "", hrefFor(pageKind, videoId));
if (controlDelay > 0) {
page.dataset.fixtureTransitionPending = pageKind;
if (dispatchEvent) dispatchNavigation();
setTimeout(() => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
}, controlDelay);
return;
}
render(pageKind, videoId);
if (dispatchEvent) dispatchNavigation();
}
function recycleShort(videoId) {
const section = page.querySelector('[data-fixture-page-kind="shorts"]');
const activeRenderer = section?.querySelector(
isMobile ? "ytm-reel-video-renderer[is-active]" : "ytd-reel-video-renderer[is-active]",
);
const nextRenderer = section?.querySelector(
isMobile
? `ytm-reel-video-renderer[video-id="${videoId}"]`
: `ytd-reel-video-renderer[video-id="${videoId}"]`,
);
if (!section || !activeRenderer || !nextRenderer) return;
history.pushState({}, "", hrefFor("shorts", videoId));
section.dataset.fixtureVideoId = videoId;
activeRenderer.removeAttribute("is-active");
activeRenderer.hidden = true;
nextRenderer.hidden = false;
nextRenderer.setAttribute("is-active", "");
const nextLink = section.querySelector("#short-next");
nextLink?.setAttribute("data-fixture-video-id", firstVideoId);
nextLink?.setAttribute("href", hrefFor("shorts", firstVideoId));
section.querySelector("#fixture-media")?.setAttribute("data-fixture-autoplay-id", firstVideoId);
}
function churnInactiveDesktopShortIdentity(sequence) {
const renderer = page.querySelector("ytd-reel-video-renderer:not([is-active])");
if (!renderer) return;
const churnVideoId = sequence % 2 === 0 ? secondVideoId : "CCCCCCCCCCC";
renderer.setAttribute("video-id", churnVideoId);
renderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${churnVideoId}`);
}
function queueDelayedRender(pageKind, videoId) {
page.dataset.fixtureTransitionPending = pageKind;
pendingDelayedRender = () => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
};
}
function finishDelayedNavigation() {
if (!pendingDelayedRender) throw new Error("No delayed fixture navigation is pending");
const finish = pendingDelayedRender;
pendingDelayedRender = null;
finish();
}
function navigateDelayedShort(videoId) {
const rendererTag = isMobile ? "ytm-reel-video-renderer" : "ytd-reel-video-renderer";
const activeRenderer = page.querySelector(`${rendererTag}[is-active]`);
for (const renderer of page.querySelectorAll(`${rendererTag}:not([is-active])`)) {
renderer.remove();
}
if (!activeRenderer) return;
activeRenderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${videoId}`);
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function anonymizeActiveDesktopShort() {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
if (!activeRenderer) return;
activeRenderer.removeAttribute("video-id");
activeRenderer.querySelector('a[href*="/shorts/"]')?.removeAttribute("href");
}
function navigateDelayedAnonymousShort(videoId) {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
for (const renderer of page.querySelectorAll("ytd-reel-video-renderer:not([is-active])")) {
renderer.remove();
}
if (!activeRenderer) return;
anonymizeActiveDesktopShort();
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function navigateDelayedWatch(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
queueDelayedRender("watch", videoId);
dispatchNavigation();
}
function beginSameNodeWatchNavigation(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
}
function finishSameNodeWatchNavigation() {
dispatchNavigation();
}
function mutateOutgoingWatchDescendant() {
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]');
if (!dislike) return;
const countMarker = document.createElement("span");
countMarker.hidden = true;
countMarker.setAttribute("data-fixture-irrelevant-watch-count-mutation", "true");
dislike.querySelector("#text")?.appendChild(countMarker);
const tooltipMarker = document.createElement("span");
tooltipMarker.hidden = true;
tooltipMarker.setAttribute("data-fixture-irrelevant-watch-tooltip-mutation", "true");
page.querySelector(".ryd-tooltip")?.appendChild(tooltipMarker);
}
function replaceDelayedWatchControl(role) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
const replacement = control.cloneNode(true);
replacement.setAttribute("data-fixture-watch-replacement", role);
replacement.classList.remove("style-default-active");
replacement.classList.add("style-text");
replacement.querySelector("button")?.setAttribute("aria-pressed", "false");
if (role === "dislike") {
const count = replacement.querySelector("#text");
if (count) count.textContent = "";
}
control.replaceWith(replacement);
}
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) {
const section = page.querySelector('[data-fixture-page-kind="watch"]');
const currentActions = section?.querySelector("#top-level-buttons-computed");
const videoId = section?.dataset.fixtureVideoId;
if (!section || !currentActions || !videoId) return false;
const replacement = currentActions.cloneNode(true);
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
const controls = replacement.querySelector('[data-ryd-role="buttons"]');
controls?.setAttribute("data-fixture-control-video-id", videoId);
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-ryd-role="${role}"]`);
control?.classList.remove("style-default-active");
control?.classList.add("style-text");
const button = control?.querySelector("button");
button?.setAttribute("aria-pressed", "false");
button?.removeAttribute("disabled");
button?.setAttribute("aria-disabled", "false");
}
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text');
if (dislikeCount) dislikeCount.textContent = "";
currentActions.replaceWith(replacement);
globalThis.__fixtureReplacedWatchActions = currentActions;
if (retainOutgoing) {
const retained = document.createElement("div");
retained.hidden = true;
retained.setAttribute("data-fixture-retained-settling-watch-actions", videoId);
retained.appendChild(currentActions);
document.body.appendChild(retained);
}
return true;
}
function refreshReusedWatchControl(role, videoId) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
}
function driftWatchControlBeforeNavigation(role, videoId) {
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`);
if (!button) return;
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
button.setAttribute("title", `${role} title drift while ${videoId}`);
}
function replaceActiveMobileOverlay() {
const activeRenderer = page.querySelector("ytm-reel-video-renderer[is-active]");
const overlay = activeRenderer?.querySelector("ytm-reel-player-overlay-renderer");
if (!overlay) return;
const replacement = overlay.cloneNode(true);
replacement.setAttribute("data-fixture-replacement", "true");
const dislike = replacement.querySelector('[data-ryd-role="dislike"]');
dislike?.classList.remove("style-default-active");
dislike?.classList.add("style-text");
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
const dislikeCount = dislike?.querySelector("#text");
if (dislikeCount) dislikeCount.textContent = "";
overlay.replaceWith(replacement);
}
function navigateToShortsDescendant(videoId) {
history.pushState({}, "", `/shorts/${videoId}/extra?${marker}`);
dispatchNavigation();
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-fixture-page-kind]");
if (!link) return;
event.preventDefault();
if (link.id === "short-next") {
recycleShort(link.dataset.fixtureVideoId);
dispatchNavigation();
return;
}
navigate(link.dataset.fixturePageKind, link.dataset.fixtureVideoId, {
controlDelay: Number(link.dataset.fixtureControlDelay || 0),
});
});
document.addEventListener(
"ended",
(event) => {
const media = event.target.closest("#fixture-media");
if (!media) return;
if (media.dataset.fixtureAutoplayKind === "shorts") {
recycleShort(media.dataset.fixtureAutoplayId);
return;
}
navigate(media.dataset.fixtureAutoplayKind, media.dataset.fixtureAutoplayId, {
dispatchEvent: false,
});
},
true,
);
window.__navigationFixture = {
anonymizeActiveDesktopShort,
beginSameNodeWatchNavigation,
churnInactiveDesktopShortIdentity,
dispatchEnded() {
page.querySelector("#fixture-media")?.dispatchEvent(new Event("ended"));
},
finishDelayedNavigation,
finishSameNodeWatchNavigation,
driftWatchControlBeforeNavigation,
navigate,
navigateDelayedAnonymousShort,
navigateDelayedShort,
navigateDelayedWatch,
navigateToShortsDescendant,
mutateOutgoingWatchDescendant,
recycleShort,
replaceActiveMobileOverlay,
replaceCurrentWatchActions,
replaceDelayedWatchControl,
refreshReusedWatchControl,
};
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,348 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript Shorts browser-test fixture</title>
<style>
html,
body {
width: 100%;
min-height: 768px;
margin: 0;
}
#shorts-root,
[data-short-video],
ytd-reel-video-renderer,
reel-action-bar-view-model,
ytd-like-button-renderer,
ytm-like-button-renderer,
like-button-view-model,
dislike-button-view-model {
display: block;
}
[data-short-video][hidden] {
display: none;
}
ytd-like-button-renderer,
ytm-like-button-renderer {
width: 240px;
min-height: 96px;
}
ytd-reel-video-renderer {
display: block;
width: 420px;
min-height: 640px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
width: 48px;
min-height: 320px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
like-button-view-model .ytSpecButtonShapeNextIcon {
display: flex;
height: 24px;
width: 24px;
}
like-button-view-model .ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
box-sizing: border-box;
display: flex;
font-family: Arial, sans-serif;
font-size: 12px;
height: 22px;
justify-content: center;
line-height: 18px;
width: 48px;
}
ytm-like-button-renderer button,
ytd-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
</style>
</head>
<body>
<div id="fixture-account"></div>
<div id="shorts-root"></div>
<script>
(() => {
const root = document.getElementById("shorts-root");
const isMobile = location.hostname === "m.youtube.com";
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
document.getElementById("fixture-account").innerHTML = __SIGNED_IN__
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
function controlsMarkup(videoId) {
if (!isMobile) {
return `
<section data-short-video="${videoId}" hidden>
<ytd-reel-video-renderer video-id="${videoId}">
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21"></path></svg></span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel">
<span
id="text"
class="ytAttributedStringHost ytAttributedStringTextAlignmentCenter"
role="text"
>100</span>
</div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share"><span role="text">Share</span></button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix"><span role="text">Remix</span></button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</section>
`;
}
return `
<section data-short-video="${videoId}" hidden>
<div id="like-button">
<__SHORTS_RENDERER_TAG__ data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</__SHORTS_RENDERER_TAG__>
</div>
</section>
`;
}
function applyNativeState(section, state) {
const like = section.querySelector('[data-ryd-role="like"]');
const dislike = section.querySelector('[data-ryd-role="dislike"]');
if (isMobile) {
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
dislike?.classList.toggle("style-text", state !== "disliked");
} else {
like.classList.toggle("style-default-active", state === "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
}
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike?.querySelector("button")?.setAttribute("aria-pressed", String(state === "disliked"));
section.dataset.nativeState = state;
}
function ensureVideo(videoId) {
let section = root.querySelector(`[data-short-video="${videoId}"]`);
if (!section) {
root.insertAdjacentHTML("beforeend", controlsMarkup(videoId));
section = root.querySelector(`[data-short-video="${videoId}"]`);
applyNativeState(section, "neutral");
}
return section;
}
function activate(videoId, { dispatchNavigation = true, state = "neutral" } = {}) {
const active = ensureVideo(videoId);
for (const section of root.querySelectorAll("[data-short-video]")) {
section.hidden = section !== active;
}
applyNativeState(active, state);
history.pushState({}, "", `/shorts/${videoId}`);
if (dispatchNavigation) {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
}
function activeSection() {
return root.querySelector("[data-short-video]:not([hidden])");
}
function removeSyntheticDislike() {
activeSection()?.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
}
function replaceActionBar() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar) return;
const replacement = actionBar.cloneNode(true);
replacement.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
actionBar.replaceWith(replacement);
applyNativeState(section, section.dataset.nativeState || "neutral");
}
function installNativeDislike() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar || actionBar.querySelector("dislike-button-view-model")) return;
const nativeDislike = document.createElement("dislike-button-view-model");
nativeDislike.className = "style-text";
nativeDislike.setAttribute("data-ryd-role", "dislike");
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-label", "Dislike this video");
button.setAttribute("aria-pressed", "false");
const text = document.createElement("span");
text.id = "text";
text.setAttribute("role", "text");
button.appendChild(text);
nativeDislike.appendChild(button);
actionBar.insertBefore(nativeDislike, actionBar.children[1] ?? null);
}
function removeNativeDislike() {
activeSection()?.querySelector("dislike-button-view-model")?.remove();
}
function replaceInnerButton(role) {
const control = activeSection()?.querySelector(`[data-ryd-role="${role}"]`);
const button = control?.querySelector("button");
if (!button) return;
const replacement = button.cloneNode(true);
if (control.matches("[data-ryd-synthetic-shorts-dislike]")) {
replacement.disabled = true;
replacement.setAttribute("aria-disabled", "true");
}
button.replaceWith(replacement);
}
function recycleActiveRenderer(videoId, { state = "neutral" } = {}) {
const section = activeSection();
const renderer = section?.querySelector("ytd-reel-video-renderer");
if (!section || !renderer) return;
section.setAttribute("data-short-video", videoId);
renderer.setAttribute("video-id", videoId);
const link = renderer.querySelector('a[href*="/shorts/"]');
link?.setAttribute("href", `/shorts/${videoId}`);
applyNativeState(section, state);
history.pushState({}, "", `/shorts/${videoId}`);
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function setClassOnlyLiked() {
const section = activeSection();
if (!section) return;
applyNativeState(section, "liked");
section.querySelector('[data-ryd-role="like"]').classList.add("style-default-active");
section.querySelector('[data-ryd-role="like"] button').setAttribute("aria-pressed", "false");
}
root.addEventListener(
"click",
(event) => {
const actionButton = event.target.closest("button");
if (!actionButton) return;
const section = event.target.closest("[data-short-video]");
if (!section) return;
const current = section.dataset.nativeState || "neutral";
if (actionButton.closest('[data-ryd-role="like"]')) {
applyNativeState(section, current === "liked" ? "neutral" : "liked");
}
if (
actionButton.closest('[data-ryd-role="dislike"]') &&
!actionButton.closest("[data-ryd-synthetic-shorts-dislike]")
) {
applyNativeState(section, current === "disliked" ? "neutral" : "disliked");
}
},
true,
);
window.__shortsFixture = {
activate,
activeSection,
ensureVideo,
installNativeDislike,
recycleActiveRenderer,
replaceInnerButton,
removeNativeDislike,
removeSyntheticDislike,
replaceActionBar,
setClassOnlyLiked,
};
activate("__VIDEO_ID__", { dispatchNavigation: false, state: "__INITIAL_STATE__" });
ensureVideo("__SECOND_VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,250 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript browser-test fixture</title>
<style>
:root {
--yt-spec-10-percent-layer: rgba(255, 255, 255, 0.1);
--yt-spec-base-background: rgb(15, 15, 15);
--yt-spec-icon-disabled: rgba(255, 255, 255, 0.3);
--yt-spec-text-primary: rgb(241, 241, 241);
--yt-spec-text-secondary: rgb(170, 170, 170);
}
html,
body {
background: rgb(15, 15, 15);
width: 100%;
min-height: 768px;
margin: 0;
}
ytd-watch-flexy,
ytd-menu-renderer,
.slim-video-action-bar-actions,
.segmented-buttons,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model {
display: block;
}
ytd-menu-renderer.ytd-watch-metadata > div {
display: block;
width: min(320px, 100%);
min-height: 36px;
}
#top-row {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 0;
}
.segmented-buttons {
display: flex;
gap: 8px;
width: 320px;
min-height: 48px;
}
[data-fixture-shell][hidden] {
display: none;
}
[data-ryd-role="like"],
[data-ryd-role="dislike"] {
box-sizing: border-box;
flex: 0 0 96px;
height: 36px;
margin: 0;
padding: 0;
width: 96px;
}
[data-ryd-role="like"] button,
[data-ryd-role="dislike"] button {
align-items: center;
background: rgb(39, 39, 39);
border: 0;
border-radius: 18px;
box-sizing: border-box;
color: rgb(241, 241, 241);
display: inline-flex;
font:
500 14px/20px Arial,
sans-serif;
gap: 6px;
height: 36px;
justify-content: center;
margin: 0;
min-height: 36px;
min-width: 96px;
padding: 0 12px;
width: 96px;
}
[data-ryd-role].style-default-active button {
background: rgb(241, 241, 241);
color: rgb(15, 15, 15);
}
[data-fixture-icon] {
display: block;
flex: 0 0 20px;
height: 20px;
margin: 0;
padding: 0;
width: 20px;
}
[data-ryd-role] #text {
display: block;
font:
500 14px/20px Arial,
sans-serif;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<!-- A hidden menu-container selects the desktop metadata layout used by the real page. -->
<div id="menu-container" style="display: none"></div>
<div id="fixture-account"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__"></ytd-watch-flexy>
<div id="top-row" data-fixture-shell="desktop">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"></div>
</ytd-menu-renderer>
</div>
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
</div>
<script>
(() => {
const account = document.getElementById("fixture-account");
const watch = document.getElementById("fixture-watch");
const isMobile = location.hostname === "m.youtube.com";
const desktopHost = document.getElementById("top-level-buttons-computed");
const mobileHost = document.getElementById("fixture-mobile-buttons-host");
const host = isMobile ? mobileHost : desktopHost;
document.querySelector('[data-fixture-shell="desktop"]').hidden = isMobile;
document.querySelector('[data-fixture-shell="mobile"]').hidden = !isMobile;
function setSignedIn(signedIn) {
account.innerHTML = signedIn
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
}
function desktopButtonMarkup() {
return `
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
`;
}
function mobileButtonMarkup() {
return `
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
`;
}
function applyNativeState(state) {
const like = host.querySelector('[data-ryd-role="like"]');
const dislike = host.querySelector('[data-ryd-role="dislike"]');
if (!like || !dislike) return;
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike.classList.toggle("style-default-active", state === "disliked");
dislike.classList.toggle("style-text", state !== "disliked");
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike.querySelector("button").setAttribute("aria-pressed", String(state === "disliked"));
host.dataset.nativeState = state;
}
function insertButtons(state = "neutral") {
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
applyNativeState(state);
}
function removeButtons() {
host.replaceChildren();
host.dataset.nativeState = "neutral";
}
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
history.pushState({}, "", `/watch?v=${videoId}`);
watch.setAttribute("video-id", videoId);
if (buttons) insertButtons(state);
else removeButtons();
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
// Model the native YouTube toggle before userscript listeners observe the bubbled click.
host.addEventListener(
"click",
(event) => {
const like = event.target.closest('[data-ryd-role="like"]');
const dislike = event.target.closest('[data-ryd-role="dislike"]');
const current = host.dataset.nativeState || "neutral";
if (like) applyNativeState(current === "liked" ? "neutral" : "liked");
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked");
},
true,
);
window.__youtubeFixture = {
applyNativeState,
insertButtons,
navigate,
removeButtons,
setSignedIn,
};
setSignedIn(__SIGNED_IN__);
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
})();
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
const fs = require("fs");
const path = require("path");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const GENERATED_USERSCRIPT =
process.env.RYD_USERSCRIPT_ARTIFACT ||
path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js");
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const CREDENTIAL_KEY = "rydVoteCredentials";
const VIDEO_A = "abcdefghijk";
const VIDEO_B = "zyxwvutsrqp";
const ZERO_DIFFICULTY_PUZZLE = {
challenge: Buffer.alloc(16).toString("base64"),
difficulty: 0,
};
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function requestKey(method, pathname) {
return `${method.toUpperCase()} ${pathname}`;
}
function parseRequestBody(request) {
const text = request.postData();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function jsonHeaders() {
return {
"access-control-allow-headers": "Accept, Content-Type",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-origin": "*",
"content-type": "application/json; charset=utf-8",
};
}
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
const blockedRequests = [];
const requests = [];
const responsePlans = new Map();
const fixtureOptions = {
initialButtons: fixture.initialButtons !== false,
initialState: fixture.initialState || "neutral",
signedIn: fixture.signedIn !== false,
};
function enqueue(method, pathname, response) {
const key = requestKey(method, pathname);
const queue = responsePlans.get(key) || [];
queue.push(response);
responsePlans.set(key, queue);
}
function defer(method, pathname) {
let releaseResponse;
let resolveSeen;
let released = false;
const seen = new Promise((resolve) => {
resolveSeen = resolve;
});
enqueue(method, pathname, (record) => {
resolveSeen(record);
return new Promise((resolve) => {
releaseResponse = resolve;
});
});
return {
get released() {
return released;
},
release(response) {
if (released) return;
if (!releaseResponse) {
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
}
released = true;
releaseResponse(response);
},
seen,
};
}
function requestsFor(method, pathname) {
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
}
function takePlannedResponse(record) {
const queue = responsePlans.get(requestKey(record.method, record.pathname));
if (!queue?.length) return null;
const planned = queue.shift();
return typeof planned === "function" ? planned(record) : planned;
}
function defaultApiResponse(record) {
if (record.method === "GET" && record.pathname === "/configs/selectors") {
return { body: {} };
}
if (record.method === "GET" && record.pathname === "/votes") {
const videoId = record.query.videoId;
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
return {
body: { ...counts, rating: 4.5 },
delayMs: countDelayByVideo[videoId] || 0,
};
}
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
return { body: true };
}
if (record.method === "POST" && record.pathname === "/interact/vote") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
return { body: true };
}
return null;
}
async function handle(route) {
const request = route.request();
const url = new URL(request.url());
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
const isShorts = url.pathname.startsWith("/shorts/");
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
const html = fixtureTemplate
.replaceAll("__VIDEO_ID__", videoId)
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
.replaceAll(
"__SHORTS_RENDERER_TAG__",
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
)
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
await route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: html,
});
return;
}
if (url.origin !== API_ORIGIN) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
return;
}
const record = {
at: Date.now(),
body: parseRequestBody(request),
method: request.method(),
pathname: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
url: url.toString(),
};
requests.push(record);
const plannedResponse = takePlannedResponse(record);
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
if (!response) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (response.delayMs) await delay(response.delayMs);
const body = response.body === undefined ? null : response.body;
await route.fulfill({
status: response.status || 200,
headers: { ...jsonHeaders(), ...(response.headers || {}) },
body: typeof body === "string" ? body : JSON.stringify(body),
});
record.respondedAt = Date.now();
}
return {
blockedRequests,
defer,
enqueue,
handle,
requests,
requestsFor,
};
}
async function installGmEnvironment(context, initialValues = {}) {
await context.addInitScript(
({ initialValues: seededValues, storagePrefix }) => {
if (!location.hostname.endsWith("youtube.com")) return;
const storageKey = (key) => `${storagePrefix}${key}`;
const read = (key, fallbackValue) => {
const stored = localStorage.getItem(storageKey(key));
if (stored === null) return fallbackValue;
try {
return JSON.parse(stored);
} catch {
return fallbackValue;
}
};
const write = (key, value) => {
if (value === undefined) localStorage.removeItem(storageKey(key));
else localStorage.setItem(storageKey(key), JSON.stringify(value));
};
for (const [key, value] of Object.entries(seededValues)) {
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
}
globalThis.__gmCalls = [];
const getValue = async (key, fallbackValue) => {
globalThis.__gmCalls.push({ operation: "get", key });
return read(key, fallbackValue);
};
const setValue = async (key, value) => {
globalThis.__gmCalls.push({ operation: "set", key, value });
write(key, value);
};
const deleteValue = async (key) => {
globalThis.__gmCalls.push({ operation: "delete", key });
localStorage.removeItem(storageKey(key));
};
const addStyle = (css) => {
const style = document.createElement("style");
style.dataset.rydGmStyle = "true";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
};
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
globalThis.GM_getValue = getValue;
globalThis.GM_setValue = setValue;
globalThis.GM_deleteValue = deleteValue;
globalThis.GM_addStyle = addStyle;
},
{
initialValues,
storagePrefix: "ryd-e2e-gm:",
},
);
}
async function installHermeticRoutes(context, backend) {
await context.route("**/*", (route) => backend.handle(route));
}
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openNavigationFixture(
page,
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
) {
const marker = "rydNavigationFixture=1";
const path =
pageKind === "shorts"
? `/shorts/${videoId}?${marker}`
: pageKind === "watch"
? `/watch?v=${videoId}&${marker}`
: `/@FixtureChannel?${marker}`;
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
}
async function forbidUnsafeHtmlSinks(page) {
await page.evaluate(() => {
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
throw new Error("Element.innerHTML descriptor is unavailable");
}
globalThis.__rydUnsafeHtmlSinkCalls = [];
const reject = (sink) => {
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
};
Object.defineProperty(Element.prototype, "innerHTML", {
configurable: innerHtmlDescriptor.configurable,
enumerable: innerHtmlDescriptor.enumerable,
get: innerHtmlDescriptor.get,
set() {
reject("Element.innerHTML");
},
});
Element.prototype.insertAdjacentHTML = function () {
reject("Element.insertAdjacentHTML");
};
});
}
function overrideBooleanOption(source, optionName, value) {
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
const matches = source.match(optionPattern) || [];
if (matches.length !== 1) {
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
}
return source.replace(optionPattern, `${optionName}: ${value}`);
}
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) {
if (!fs.existsSync(GENERATED_USERSCRIPT)) {
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`);
}
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
return;
}
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
if (disableVoteSubmission) {
source = overrideBooleanOption(source, "disableVoteSubmission", true);
}
if (rateBarEnabled !== undefined) {
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
}
if (coloredThumbs !== undefined) {
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
}
await page.addScriptTag({ content: source });
}
async function readGmValue(page, key) {
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
}
module.exports = {
API_ORIGIN,
CREDENTIAL_KEY,
GENERATED_USERSCRIPT,
VIDEO_A,
VIDEO_B,
ZERO_DIFFICULTY_PUZZLE,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
openShortsFixture,
openWatchFixture,
readGmValue,
};
+168
View File
@@ -0,0 +1,168 @@
# Live YouTube smoke
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI.
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
coverage remain in the hermetic suites.
## Browser preparation
1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this
browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance;
`http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave.
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible
to the signed-in profile and must have different rendered dislike counts.
3. Enable exactly one runtime:
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run
`npm run build:live:userscript`, import
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it.
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run
`npm run build:live:extension`, then enable or reload only the unpacked extension at
`Extensions/combined/dist/chrome`.
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the
runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke.
4. Keep the Brave window open and do not interact with the tab created by the suite. The suite creates, uses, and closes
only its own tab.
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal
production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies
that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is
absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
There is no environment override for the build ID.
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API,
including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
it blocks production `POST /interact/*` requests before transmission as described below.
## Non-voting smoke
Set the following in PowerShell. Video IDs are the 11-character values from YouTube URLs, and the playlist URL must
start on `RYD_LIVE_WATCH_A`.
```powershell
$env:RYD_LIVE_YOUTUBE="1"
$env:RYD_LIVE_PRODUCTION_API="1"
$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222"
$env:RYD_LIVE_RUNTIME="userscript"
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
$env:RYD_LIVE_SHORT="CCCCCCCCCCC"
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID"
# Optional overrides for the cold channel-navigation smoke. These are the defaults:
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@SmashTrash"
$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4"
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
$env:RYD_LIVE_SIDEBAR_HOPS="3"
npm run test:live:youtube
```
The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to
`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured
channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
that the selected runtime initialized the current Short (including exactly one visible synthetic control in userscript
mode), clicks YouTube's visible **Next video** control, proves that transition also reused the document, and verifies a
new current video ID and initialized control. The channel-to-Short check runs first in the fresh suite tab so earlier
watch or Shorts visits cannot warm the page lifecycle it is meant to exercise.
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. The live
driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly
one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario
fails; it never loops or clicks a reaction control as part of this retry.
After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks,
so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears.
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible
`#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
skipped. Every hop waits for the exact production `/votes?videoId=<target>` response and a rendered dislike count,
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that
same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic
evidence paths are overwritten on each run at
`test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
extension mode.
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production
`POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts
from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context;
do not use another tab in that Brave profile to react to a video while the smoke is running.
Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite.
This production smoke intentionally exercises the visible Shorts **Next video** control only: live autoplay timing,
recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a
reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition.
The default channel dataset can drift as its public page changes. Override both variables with a channel URL and Short
that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its
`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID.
An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured
channel page. Opt in with an 11-character ID; otherwise that scenario is skipped:
```powershell
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD"
```
For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval,
it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the
modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it
checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes;
typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own
mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced
by the RYD bar still fails.
The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LIVE_CHECKPOINT` records while it works.
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while
validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation,
account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
pending operation.
If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD
API request outcomes before closing its test tab. It writes a JSON snapshot under
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes
the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state,
and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
reporting a live-only failure; ordinary direct interactive runs otherwise have no Playwright trace.
Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime to execute the same smoke against the
extension. The expected version defaults to the local userscript candidate version or root package version; set
`RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
## Optional production reaction matrix
The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one
matching successful `/interact/confirmVote`. Any fourth vote request, changed identity/video/value, extra confirmation,
or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to
be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with
the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
restoration.
The matrix also captures the initial state and every post-transition state, for seven watch images and seven Shorts
images. Before each screenshot it reads both `aria-pressed` values, verifies the exact expected mutually-exclusive
state, and requires a numeric dislike count. Watch captures additionally require a visible, non-overlapping ratio bar
with sane geometry. Userscript Shorts captures reuse the strict native-vs-synthetic control geometry checks, including
the 48x78 action host, 24x24 icon, typography, spacing, active-reel ownership, and duplicate-control checks. Evidence is
written with deterministic names under
`test-results/live-youtube/reactions/{runtime}/{watch|short}-{0..6}-{neutral|liked|disliked}.png`. A failed visual check
still enters the same verified restoration path before the scenario reports the failure.
The exact live-build marker proves that the freshly generated test build is present and that the opposite runtime is
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
is mandatory.
Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately
before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently
authorize a later extension run.
```powershell
$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
npm run test:live:youtube
Remove-Item Env:RYD_LIVE_VOTES
```
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile.
@@ -0,0 +1,358 @@
const fs = require("node:fs");
const path = require("node:path");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
const MAX_BROWSER_SIGNALS = 50;
const MAX_API_REQUESTS = 50;
function serializeError(error) {
if (!error) return null;
if (typeof error === "string") return { message: error, name: "Error", stack: null };
return {
message: String(error.message ?? error),
name: String(error.name ?? "Error"),
stack: typeof error.stack === "string" ? error.stack : null,
};
}
function installUnhandledRejectionListener(prefix) {
if (globalThis.__rydLiveUnhandledRejectionListenerInstalled) return;
globalThis.__rydLiveUnhandledRejectionListenerInstalled = true;
globalThis.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
let message;
try {
if (reason instanceof Error)
message = `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`;
else if (typeof reason === "string") message = reason;
else message = JSON.stringify(reason);
} catch {
message = String(reason);
}
console.error(`${prefix}${message ?? "Unknown rejection"}`);
});
}
function readLivePageState() {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
return {
bottom: rect.bottom,
height: rect.height,
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
};
const isVisible = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < innerHeight &&
rect.left < innerWidth &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const actionBarSelector = "reel-action-bar-view-model, .slim-video-action-bar-actions";
const syntheticSelector = "[data-ryd-synthetic-shorts-dislike]";
const renderers = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")];
return {
document: {
readyState: document.readyState,
title: document.title,
url: location.href,
viewport: { height: innerHeight, width: innerWidth },
},
runtimeMarkers: {
extensionBuild: document.documentElement.getAttribute("data-ryd-extension-build"),
extension: document.documentElement.getAttribute("data-ryd-extension-version"),
userscriptBuild: document.documentElement.getAttribute("data-ryd-userscript-build"),
userscript: document.documentElement.getAttribute("data-ryd-userscript-version"),
},
renderers: renderers.map((renderer, index) => ({
actionBars: renderer.querySelectorAll(actionBarSelector).length,
ariaHidden: renderer.getAttribute("aria-hidden"),
index,
isActive: renderer.getAttribute("is-active"),
links: [...renderer.querySelectorAll('a[href*="/shorts/"]')].map((link) => ({
href: link.getAttribute("href"),
visible: isVisible(link),
})),
rect: readRect(renderer),
syntheticControls: renderer.querySelectorAll(syntheticSelector).length,
tagName: renderer.tagName.toLowerCase(),
videoId: renderer.getAttribute("video-id"),
visible: isVisible(renderer),
})),
actionBars: [...document.querySelectorAll(actionBarSelector)].map((actionBar, index) => ({
index,
nativeDislikes: actionBar.querySelectorAll("dislike-button-view-model, #dislike-button").length,
nativeLikes: actionBar.querySelectorAll("like-button-view-model, #like-button").length,
rect: readRect(actionBar),
syntheticControls: actionBar.querySelectorAll(syntheticSelector).length,
tagName: actionBar.tagName.toLowerCase(),
videoId: actionBar.closest("ytd-reel-video-renderer, ytm-reel-video-renderer")?.getAttribute("video-id") ?? null,
visible: isVisible(actionBar),
})),
syntheticControls: [...document.querySelectorAll(syntheticSelector)].map((control, index) => ({
ariaPressed: control.querySelector("button")?.getAttribute("aria-pressed") ?? null,
index,
rect: readRect(control),
text: (control.textContent ?? "").replace(/\s+/g, " ").trim(),
videoId: control.getAttribute("data-ryd-video-id"),
visible: isVisible(control),
})),
videos: [...document.querySelectorAll("video")].map((video, index) => ({
currentTime: video.currentTime,
ended: video.ended,
index,
paused: video.paused,
readyState: video.readyState,
rect: readRect(video),
visible: isVisible(video),
})),
};
}
function diagnosticApiUrl(value) {
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.origin !== API_ORIGIN) return null;
const query = {};
for (const [name, requestValue] of url.searchParams) {
query[name] = /auth|key|puzzle|secret|solution|token|user/i.test(name) ? "<redacted>" : requestValue;
}
return { pathname: url.pathname, query };
}
function timestampForFilename(date) {
return date.toISOString().replace(/[:.]/g, "-");
}
class LiveRunDiagnostics {
constructor(
page,
context,
{
clock = () => new Date(),
fileSystem = fs,
log = console.log,
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
runtime = null,
} = {},
) {
this.browserSignals = [];
this.clock = clock;
this.context = context;
this.currentCheckpoint = null;
this.currentStage = "startup";
this.fileSystem = fileSystem;
this.log = log;
this.outputDirectory = outputDirectory;
this.page = page;
this.recentApiRequests = [];
this.requestRecords = new WeakMap();
this.runtime = runtime;
this.started = false;
this.onConsole = this.onConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onRequest = this.onRequest.bind(this);
this.onRequestFailed = this.onRequestFailed.bind(this);
this.onResponse = this.onResponse.bind(this);
}
now() {
return this.clock().toISOString();
}
appendCapped(collection, value, maximum) {
collection.push(value);
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
}
recordBrowserSignal(type, details) {
const signal = {
at: this.now(),
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
type,
...details,
};
this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS);
this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`);
}
onPageError(error) {
this.recordBrowserSignal("pageerror", { error: serializeError(error) });
}
onConsole(message) {
if (message.type() !== "error") return;
const text = message.text();
const location = message.location?.() ?? {};
if (text.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordBrowserSignal("unhandledrejection", {
location,
message: text.slice(UNHANDLED_REJECTION_PREFIX.length),
});
return;
}
this.recordBrowserSignal("console.error", { location, message: text });
}
onRequest(request) {
const apiUrl = diagnosticApiUrl(request.url());
if (!apiUrl) return;
const record = {
...apiUrl,
at: this.now(),
failure: null,
method: request.method(),
resourceType: typeof request.resourceType === "function" ? request.resourceType() : null,
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
status: null,
};
this.appendCapped(this.recentApiRequests, record, MAX_API_REQUESTS);
this.requestRecords.set(request, record);
}
onResponse(response) {
const record = this.requestRecords.get(response.request());
if (!record) return;
record.status = response.status();
}
onRequestFailed(request) {
const record = this.requestRecords.get(request);
if (!record) return;
record.failure = request.failure()?.errorText ?? "Unknown request failure";
}
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onConsole);
this.page.on("pageerror", this.onPageError);
this.context.on("request", this.onRequest);
this.context.on("requestfailed", this.onRequestFailed);
this.context.on("response", this.onResponse);
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (!this.page.isClosed()) {
await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
}
}
stop() {
if (!this.started) return;
this.started = false;
this.page.off("console", this.onConsole);
this.page.off("pageerror", this.onPageError);
this.context.off("request", this.onRequest);
this.context.off("requestfailed", this.onRequestFailed);
this.context.off("response", this.onResponse);
}
checkpoint(name, details = {}) {
this.currentCheckpoint = name;
this.log(`LIVE_CHECKPOINT ${name} ${JSON.stringify(details)}`);
}
stageStarted(name) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_START ${name}`);
}
stageCompleted(name, startedAt) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_COMPLETE ${name} durationMs=${Date.now() - startedAt}`);
}
stageFailed(name, startedAt, error) {
this.currentStage = name;
this.log(
`LIVE_STAGE_FAILED ${name} durationMs=${Date.now() - startedAt} error=${JSON.stringify(error?.message ?? String(error))}`,
);
}
async snapshot(error) {
let pageState = null;
let pageStateError = null;
let url = null;
try {
url = this.page.url();
} catch {
// A disconnected page still produces a useful harness/request snapshot.
}
if (!this.page.isClosed()) {
try {
pageState = await this.page.evaluate(readLivePageState);
} catch (snapshotError) {
pageStateError = serializeError(snapshotError);
}
}
return {
browserSignals: this.browserSignals,
capturedAt: this.now(),
currentCheckpoint: this.currentCheckpoint,
currentStage: this.currentStage,
error: serializeError(error),
pageClosed: this.page.isClosed(),
pageState,
pageStateError,
recentApiRequests: this.recentApiRequests,
runtime: this.runtime,
url,
};
}
async persistFailureSnapshot(error) {
const snapshot = await this.snapshot(error);
this.fileSystem.mkdirSync(this.outputDirectory, { recursive: true });
const fileName = `failure-${timestampForFilename(this.clock())}.json`;
const snapshotPath = path.join(this.outputDirectory, fileName);
this.fileSystem.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
return snapshotPath;
}
}
async function runLoggedStage(diagnostics, name, action) {
const startedAt = Date.now();
diagnostics.stageStarted(name);
try {
const result = await action();
diagnostics.stageCompleted(name, startedAt);
return result;
} catch (error) {
diagnostics.stageFailed(name, startedAt, error);
throw error;
}
}
module.exports = {
API_ORIGIN,
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
installUnhandledRejectionListener,
readLivePageState,
runLoggedStage,
serializeError,
};
@@ -0,0 +1,138 @@
const readline = require("node:readline");
const { chromium } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const scenarioRunner = createSharedLiveScenarioRunner();
function readApprovalLine() {
const input = readline.createInterface({ input: process.stdin, terminal: false });
return new Promise((resolve) => {
input.once("line", (line) => {
input.close();
resolve(line.trim());
});
});
}
async function main() {
const options = readLiveOptions();
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
let browser;
let diagnostics;
let page;
try {
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL");
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 120_000,
});
const [context] = browser.contexts();
if (!context) throw new Error("The attached Chromium browser has no default context.");
page = await context.newPage();
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime });
await diagnostics.start();
const driver = new LiveYoutubeDriver(page, context, {
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
const runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
console.log("BRAVE_CONNECTED");
const readOnly = {};
readOnly.channelShorts = await runLoggedStage(diagnostics, "read-only.channel-to-shorts-and-next", () =>
scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options),
);
if (options.navigation.watch) {
readOnly.channelWatch = await runLoggedStage(diagnostics, "read-only.channel-to-watch", () =>
scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options),
);
} else {
readOnly.channelWatch = null;
diagnostics.checkpoint("read-only.channel-to-watch.skipped", {
reason: "RYD_LIVE_NAV_WATCH is not configured",
});
}
readOnly.reload = await runLoggedStage(diagnostics, "read-only.reload", () =>
scenarioRunner.run(runtimeAdapter, "reload", options),
);
readOnly.responsive = await runLoggedStage(diagnostics, "read-only.responsive-visual", () =>
scenarioRunner.run(runtimeAdapter, "responsive-visual", options),
);
readOnly.short = await runLoggedStage(diagnostics, "read-only.short-render", () =>
scenarioRunner.run(runtimeAdapter, "shorts-render", options),
);
readOnly.spa = await runLoggedStage(diagnostics, "read-only.playlist-spa", () =>
scenarioRunner.run(runtimeAdapter, "spa-navigation", options),
);
readOnly.sidebarStress = await runLoggedStage(diagnostics, "read-only.sidebar-stress", () =>
scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options),
);
readOnly.watch = await runLoggedStage(diagnostics, "read-only.watch-render", () =>
scenarioRunner.run(runtimeAdapter, "watch-render", options),
);
console.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
console.log(
`READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} short=${options.short}`,
);
diagnostics.checkpoint("reaction-approval.waiting", {
instruction: "Enter SKIP to close the live-test tab without production reactions",
});
const approval = await readApprovalLine();
if (approval === "SKIP") {
console.log("REACTION_MATRIX_SKIPPED");
return;
}
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) {
throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
}
const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () =>
scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
}),
);
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`);
} catch (error) {
if (diagnostics) {
try {
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
} catch (snapshotError) {
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
}
}
throw error;
} finally {
diagnostics?.stop();
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,568 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
const RESPONSIVE_VIEWPORTS = [
{ height: 720, width: 1280 },
{ height: 720, width: 768 },
{ height: 844, width: 390 },
];
const REACTION_CYCLES = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
function nextReactionState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked";
if (action === "dislike") return state === "disliked" ? "neutral" : "disliked";
throw new Error(`Unsupported reaction action: ${action}`);
}
function reactionValue(state) {
if (state === "liked") return 1;
if (state === "disliked") return -1;
if (state === "neutral") return 0;
throw new Error(`Unsupported reaction state: ${state}`);
}
async function assertLivePreconditions(driver, options) {
await driver.assertSignedIn(options.expectedChannel);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
}
async function runWatchRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runReloadScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
await driver.reload(options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runSpaNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
const watchACount = await driver.waitForDislikeText();
await driver.navigateWithinPlaylist(options.watchB);
await assertLivePreconditions(driver, options);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
assert.match(watchACount, /\d/);
assert.match(watchBCount, /\d/);
assert.notEqual(
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
return { watchACount, watchBCount };
});
}
async function runSidebarStressScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/sidebar-stress"),
readyTimeoutMs = 1_000,
soakDurationMs = WATCH_RATIO_SOAK_DURATION_MS,
} = {},
) {
const hopCount = options.sidebar?.hopCount;
assert.ok(Number.isSafeInteger(hopCount) && hopCount > 0, "A positive sidebar stress hop count is required.");
assert.ok(
Number.isFinite(readyTimeoutMs) && readyTimeoutMs > 0,
"A positive ratio-bar readiness budget is required.",
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const visitedVideoIds = [options.watchA];
const hops = [];
for (let index = 0; index < hopCount; index += 1) {
const { body, videoId } = await driver.navigateToRelatedWatch(visitedVideoIds);
assert.equal(typeof body.dislikes, "number", `Sidebar hop ${index + 1} has no production dislike count.`);
assert.ok(!visitedVideoIds.includes(videoId), `Sidebar hop ${index + 1} revisited ${videoId}.`);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
const screenshotPath = path.join(
outputDirectory,
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
);
const readinessStartedAt = Date.now();
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
presenceTimeoutMs: readyTimeoutMs,
});
const readyLatencyMs = Date.now() - readinessStartedAt;
assert.ok(
readyLatencyMs <= readyTimeoutMs,
`Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`,
);
assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`);
const soak = await driver.soakWatchRatioVisual(options.runtime, {
durationMs: soakDurationMs,
expectedCount: visual.count,
videoId,
});
visitedVideoIds.push(videoId);
hops.push({
apiDislikes: body.dislikes,
count: visual.count,
readyLatencyMs,
readyTimeoutMs,
screenshotPath,
soak,
videoId,
});
}
return { hopCount, hops, outputDirectory, startVideoId: options.watchA };
});
}
async function runShortsRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runChannelShortsNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short);
await assertLivePreconditions(driver, options);
const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime);
const nextVideoId = await driver.navigateToNextShort(options.navigation.short);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime);
await driver.pausePlayback();
assert.notEqual(
next.videoId,
initial.videoId,
"The Shorts Next video scenario did not change the current video ID.",
);
return { initial, next };
});
}
async function runChannelWatchNavigationScenario(driver, options) {
if (!options.navigation.watch) {
throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario.");
}
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
return { count, videoId: options.navigation.watch };
});
}
async function runResponsiveVisualScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
} = {},
) {
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const originalViewport = await driver.readViewportSize();
const watch = [];
const shorts = [];
try {
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.waitForDislikeText();
watch.push(
await driver.captureWatchRatioVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
),
);
}
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.assertCurrentShortsControl(options.short, options.runtime);
await driver.waitForDislikeText();
const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`);
shorts.push(
options.runtime === "userscript"
? await driver.captureSyntheticShortsVisual(options.short, screenshotPath)
: await driver.captureNativeShortsVisual(options.short, screenshotPath),
);
}
} finally {
await driver.setViewportSize(originalViewport);
}
return {
outputDirectory,
shorts,
shortsSkipped: null,
watch,
};
});
}
async function restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
driver.assertCurrentVideo(videoId);
} catch {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
}
const currentState = await driver.readReactionState();
if (currentState === initialState && !failedAttempt) return;
const attemptedUserId = failedAttempt?.userId;
const assertCleanupUserId = (userId, roundTripUserId) => {
assert.equal(typeof userId, "string", "The cleanup reaction has no anonymous RYD identity.");
if (expectedUserId) {
assert.equal(userId, expectedUserId, "The cleanup reaction used a different anonymous RYD identity.");
}
if (attemptedUserId) {
assert.equal(userId, attemptedUserId, "The cleanup reaction did not use the failed attempt's RYD identity.");
}
if (roundTripUserId) {
assert.equal(userId, roundTripUserId, "The cleanup round trip changed anonymous RYD identity.");
}
};
const performCleanupTransition = async (action, targetState, roundTripUserId) => {
const mark = recorder.mark();
await driver.clickAction(videoId, action);
await driver.waitForReactionState(targetState);
const userId = await recorder.waitForHandshake(reactionValue(targetState), mark);
assertCleanupUserId(userId, roundTripUserId);
return userId;
};
if (currentState === initialState) {
const action = initialState === "liked" ? "like" : "dislike";
const awayState = initialState === "neutral" ? "disliked" : "neutral";
const awayUserId = await performCleanupTransition(action, awayState);
await performCleanupTransition(action, initialState, awayUserId);
return;
}
const action =
initialState === "neutral"
? currentState === "liked"
? "like"
: "dislike"
: initialState === "liked"
? "like"
: "dislike";
await performCleanupTransition(action, initialState);
}
async function restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
await restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
} catch (error) {
const url = isShort ? `https://www.youtube.com/shorts/${videoId}` : `https://www.youtube.com/watch?v=${videoId}`;
throw new Error(`Automatic cleanup could not be verified. Manually restore ${url}. ${error.message}`, {
cause: error,
});
}
}
async function runReactionCycle(
driver,
recorder,
options,
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
) {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialState = await driver.readReactionState();
const actions = REACTION_CYCLES[initialState];
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
let authorized = false;
let completed = false;
let expectedUserId;
let failedAttempt;
let currentState = initialState;
const evidencePaths = [];
const captureEvidence = async (index) => {
if (!captureReactionVisual) return;
const screenshotPath = await captureReactionVisual({ index, state: currentState });
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
evidencePaths.push(screenshotPath);
};
try {
await captureEvidence(0);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
if (beforeFirstAction) await beforeFirstAction();
authorized = true;
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
const expectedState = nextReactionState(currentState, action);
const mark = recorder.mark();
const value = reactionValue(expectedState);
failedAttempt = { mark, userId: undefined, value };
await driver.clickAction(videoId, action);
await driver.waitForReactionState(expectedState);
const userId = await recorder.waitForHandshake(value, mark);
failedAttempt.userId = userId;
if (expectedUserId) assert.equal(userId, expectedUserId, "The transition cycle changed anonymous RYD identity.");
expectedUserId = userId;
failedAttempt = undefined;
currentState = expectedState;
await driver.waitForDislikeText();
await captureEvidence(index + 1);
}
assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state.");
completed = true;
return { evidencePaths, initialState, userId: expectedUserId };
} finally {
if (authorized && !completed) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
try {
failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark);
} catch {
failedAttempt.userId = undefined;
}
}
await restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
}
}
}
async function runProductionReactionMatrixScenario(
driver,
options,
createRecorder,
consumeVoteApproval,
visualOptions = {},
) {
const makeDirectory = visualOptions.makeDirectory ?? ((directory) => fs.mkdirSync(directory, { recursive: true }));
const outputDirectory =
visualOptions.outputDirectory ??
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
makeDirectory(outputDirectory);
const captureFor =
(kind, videoId, isShort) =>
async ({ index, state }) => {
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
const evidence = await driver.captureReactionStateVisual({
expectedState: state,
isShort,
runtime: options.runtime,
screenshotPath,
videoId,
});
assert.equal(
evidence?.screenshotPath,
screenshotPath,
`The ${kind} reaction capture did not write the requested evidence path.`,
);
return screenshotPath;
};
const watchRecorder = createRecorder(options.watchB);
let watchResult;
try {
watchResult = await runReactionCycle(driver, watchRecorder, options, {
beforeFirstAction: consumeVoteApproval,
captureReactionVisual: captureFor("watch", options.watchB, false),
videoId: options.watchB,
});
} finally {
watchRecorder.stop();
}
const shortRecorder = createRecorder(options.short);
try {
const shortResult = await runReactionCycle(driver, shortRecorder, options, {
captureReactionVisual: captureFor("short", options.short, true),
isShort: true,
videoId: options.short,
});
assert.equal(
shortResult.userId,
watchResult.userId,
"Watch and Shorts votes used different anonymous RYD identities.",
);
return {
evidencePaths: [...watchResult.evidencePaths, ...shortResult.evidencePaths],
outputDirectory,
short: shortResult,
watch: watchResult,
};
} finally {
shortRecorder.stop();
}
}
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
let state;
try {
driver.assertCurrentVideo(options.watchB);
state = await driver.readVoteState();
} catch {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
state = await driver.readVoteState();
}
if (state === "false") {
if (recorder.hasVote(-1, dislikeMark)) {
throw new Error(
`The YouTube button is neutral but the production -1 vote may remain. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
return;
}
if (state !== "true") {
throw new Error(
`Could not verify the YouTube dislike state. Manually restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
const neutralMark = recorder.mark();
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(false);
const neutralUserId = await recorder.waitForHandshake(0, neutralMark);
if (expectedUserId && neutralUserId !== expectedUserId) {
throw new Error(
`The neutral vote used a different identity from the dislike vote. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
}
async function restoreNeutralState(driver, recorder, options, dislikeMark, expectedUserId) {
try {
await restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId);
} catch (error) {
throw new Error(
`Automatic cleanup could not be verified. Manually restore https://www.youtube.com/watch?v=${options.watchB}. ${error.message}`,
{ cause: error },
);
}
}
async function runReversibleVoteScenario(driver, recorder, options, consumeVoteApproval) {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialLikeState = await driver.readLikeState();
const initialDislikeState = await driver.readVoteState();
assert.equal(initialLikeState, "false", "The allowlisted vote video must not already be liked.");
assert.equal(initialDislikeState, "false", "The allowlisted vote video must not already be disliked.");
const dislikeMark = recorder.mark();
let cleanupRequired = false;
let dislikeUserId;
try {
driver.assertCurrentVideo(options.watchB);
await assertLivePreconditions(driver, options);
await consumeVoteApproval();
cleanupRequired = true;
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(true);
dislikeUserId = await recorder.waitForHandshake(-1, dislikeMark);
} finally {
if (cleanupRequired) {
await restoreNeutralState(
driver,
recorder,
options,
dislikeMark,
dislikeUserId || recorder.voteUserId(-1, dislikeMark),
);
}
}
await driver.waitForDislikeText();
}
module.exports = {
RESPONSIVE_VIEWPORTS,
runChannelShortsNavigationScenario,
runChannelWatchNavigationScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runReloadScenario,
runReversibleVoteScenario,
runResponsiveVisualScenario,
runSidebarStressScenario,
runShortsRenderScenario,
runSpaNavigationScenario,
runWatchRenderScenario,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
const { chromium, test } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const options = readLiveOptions();
const scenarioRunner = createSharedLiveScenarioRunner();
test.describe("live YouTube RYD smoke", () => {
test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
test.describe.configure({ mode: "serial" });
let browser;
let context;
let page;
let driver;
let runtimeAdapter;
test.beforeAll(async () => {
try {
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 15_000,
});
} catch (error) {
throw new Error(
`Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`,
);
}
[context] = browser.contexts();
if (!context) throw new Error("The attached Brave/Chromium browser has no default context.");
page = await context.newPage();
driver = new LiveYoutubeDriver(page, context);
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
});
test.afterAll(async () => {
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
});
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => {
test.setTimeout(150_000);
await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options);
});
test(`${options?.runtime || "runtime"}: initializes after optional cold channel-to-watch SPA navigation`, async () => {
test.setTimeout(120_000);
test.skip(
!options?.navigation.watch,
"Set RYD_LIVE_NAV_WATCH only when the configured channel page contains an exact visible link to that video.",
);
await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options);
});
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
await scenarioRunner.run(runtimeAdapter, "watch-render", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
await scenarioRunner.run(runtimeAdapter, "reload", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options);
});
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options);
});
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
await scenarioRunner.run(runtimeAdapter, "shorts-render", options);
});
test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
test.setTimeout(180_000);
await scenarioRunner.run(runtimeAdapter, "responsive-visual", options);
});
test(`${options?.runtime || "runtime"}: covers all reaction transitions on watch and Shorts`, async () => {
test.setTimeout(0);
const voteApproval = process.env.RYD_LIVE_VOTES;
test.skip(
!hasFreshVoteApproval(voteApproval, options.runtime, options.watchB, Date.now()),
"Create the documented short-lived RYD_LIVE_VOTES token only after approving the real vote pair.",
);
await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(voteApproval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
VIDEO_B,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
readGmValue,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
function visibleVoteButton(page, role) {
return page.locator(`[data-ryd-role="${role}"]:visible button`);
}
async function installDelayedSyntheticStateRead(page) {
await page.evaluate((delayedKey) => {
const originalGetValue = globalThis.GM.getValue;
let releaseRead;
let reportReadStarted;
let delayed = false;
const readGate = new Promise((resolve) => {
releaseRead = resolve;
});
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
reportReadStarted = resolve;
});
globalThis.__releaseRydDelayedGmRead = releaseRead;
globalThis.GM.getValue = async (key, fallbackValue) => {
if (key === delayedKey) {
if (!delayed) {
delayed = true;
reportReadStarted();
await readGate;
}
}
return originalGetValue(key, fallbackValue);
};
}, SYNTHETIC_STATE_KEY);
}
async function launchDelayedShorts(
{ context, page },
{ backendOptions, configureBackend, nativeDislike = false } = {},
) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend(backendOptions);
configureBackend?.(backend);
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await page.evaluate(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(
event.reason instanceof Error ? event.reason.message : String(event.reason),
);
});
});
if (nativeDislike) {
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
}
await installDelayedSyntheticStateRead(page);
await injectGeneratedUserscript(page);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
return { backend, consoleErrors, pageErrors };
}
async function releaseDelayedRead(page) {
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
}
async function expectCompletedHandshakes(backend, expectedValues) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
await expect
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
.toBe(true);
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(expectedValues.length);
expect(confirmations).toHaveLength(expectedValues.length);
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
for (let index = 0; index < expectedValues.length; index += 1) {
expect(votes[index].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: expectedValues[index],
});
expect(confirmations[index].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
});
}
}
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(1);
expect(confirmations).toHaveLength(1);
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
}
async function expectNoRuntimeFailures(page, harness) {
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
}
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
const likeButton = visibleVoteButton(page, "like");
await likeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await likeButton.click();
await expectCompletedHandshakes(harness.backend, [1, 0]);
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
const dislikeButton = visibleVoteButton(page, "dislike");
await dislikeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await dislikeButton.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
nativeDislike: true,
},
);
await visibleVoteButton(page, "dislike").click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: -1,
});
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
await page.evaluate((videoId) => {
window.__shortsFixture.recycleActiveRenderer(videoId);
window.__shortsFixture.removeNativeDislike();
}, VIDEO_A);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
await restoredSyntheticDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
},
);
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
const videoBLike = visibleVoteButton(page, "like");
await videoBLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
for (const replacement of ["inner Like button", "action bar"]) {
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
const harness = await launchDelayedShorts({ context, page });
await page.evaluate((replacementKind) => {
if (replacementKind === "inner Like button") {
window.__shortsFixture.replaceInnerButton("like");
} else {
window.__shortsFixture.replaceActionBar();
}
}, replacement);
const replacementLike = visibleVoteButton(page, "like");
await replacementLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
await expectNoRuntimeFailures(page, harness);
});
}
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
await releaseDelayedRead(page);
const syntheticDislike = visibleVoteButton(page, "dislike");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
await installDelayedSyntheticStateRead(page);
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
await syntheticDislike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
} finally {
await releaseDelayedRead(page);
}
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 25, likes: 100 },
},
},
nativeDislike: true,
},
);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
let readReleased = false;
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
const replacementDislike = visibleVoteButton(page, "dislike");
await replacementDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
} finally {
await releaseDelayedRead(page);
readReleased = true;
}
expect(readReleased).toBe(true);
const finalDislike = visibleVoteButton(page, "dislike");
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
await page.waitForTimeout(200);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
await finalDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
configureBackend: (backend) => {
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
},
nativeDislike: true,
},
);
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
await expect(dislikeCount).toHaveText("25");
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(dislikeCount).toHaveText("26");
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
await expect(dislikeCount).toHaveText("22");
await page.evaluate(
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
VIDEO_A,
);
await expect(dislikeCount).toHaveText("26");
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
).toHaveLength(2);
} finally {
await releaseDelayedRead(page);
}
await page.waitForTimeout(250);
await expect(dislikeCount).toHaveText("26");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
} = require("./harness");
const {
NAVIGATION_MATRIX,
USERSCRIPT_MATRIX_RUNTIME,
installNavigationMatrixFixture,
runNavigationMatrixScenario,
} = require("./navigation-matrix");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential000000000001",
};
for (const scenario of NAVIGATION_MATRIX) {
test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => {
await page.setViewportSize(scenario.viewport);
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
});
const backend = createFakeBackend({
countsByVideo: {
[scenario.destination.videoId]: scenario.destination.counts,
[scenario.origin.videoId]: scenario.origin.counts,
},
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installNavigationMatrixFixture(context, scenario);
await installHermeticRoutes(context, backend);
await openNavigationFixture(page, {
pageKind: scenario.origin.kind,
videoId: scenario.origin.videoId,
});
await injectGeneratedUserscript(page);
await runNavigationMatrixScenario({
backend,
page,
runtime: USERSCRIPT_MATRIX_RUNTIME,
scenario,
});
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
}
@@ -0,0 +1,219 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide desktop", width: 1280, height: 720 },
{ name: "narrow desktop", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
function colorChannels(color) {
const srgb = color.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
if (srgb) return srgb.slice(1).map((channel) => Number(channel) * 255);
const channels = color
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
if (!channels || channels.length !== 3) throw new Error(`Unsupported computed color: ${color}`);
return channels;
}
function relativeLuminance(color) {
const channels = colorChannels(color);
const linear = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
}
function contrastRatio(first, second) {
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
async function readBarColors(page) {
return page.evaluate(() => ({
background: getComputedStyle(document.body).backgroundColor,
negative: getComputedStyle(document.querySelector("#return-youtube-dislike-bar-container")).backgroundColor,
positive: getComputedStyle(document.querySelector("#return-youtube-dislike-bar")).backgroundColor,
}));
}
function expectVisibleBarColors(colors, expected) {
expect(colors.background).toBe(expected.background);
expect(colors.positive).toBe(expected.positive);
colorChannels(colors.negative).forEach((channel) => expect(channel).toBeCloseTo(expected.negative, 0));
expect(contrastRatio(colors.negative, colors.background)).toBeGreaterThanOrEqual(3);
expect(contrastRatio(colors.positive, colors.negative)).toBeGreaterThanOrEqual(3);
}
async function launchRateBarFixture({ context, page }, { rateBarEnabled, theme = "dark" } = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ countsByVideo: { [VIDEO_A]: COUNTS } });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
if (theme === "light") {
await page.evaluate(() => {
document.documentElement.style.setProperty("--yt-spec-base-background", "rgb(255, 255, 255)");
document.documentElement.style.setProperty("--yt-spec-text-primary", "rgb(15, 15, 15)");
document.documentElement.style.setProperty("--yt-spec-text-secondary", "rgb(96, 96, 96)");
document.body.style.background = "rgb(255, 255, 255)";
});
}
await injectGeneratedUserscript(page, { rateBarEnabled });
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(COUNTS.dislikes));
return { backend, pageErrors };
}
for (const viewport of VIEWPORTS) {
test(`default ratio bar renders cleanly at ${viewport.name} width`, async ({ context, page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const { backend, pageErrors } = await launchRateBarFixture({ context, page });
const wrapper = page.locator(".ryd-tooltip");
const container = page.locator("#return-youtube-dislike-bar-container");
const bar = page.locator("#return-youtube-dislike-bar");
const tooltip = page.locator("#ryd-dislike-tooltip");
const dislikeCount = page.locator('[data-ryd-role="dislike"] #text');
await expect(dislikeCount).toBeVisible();
await expect(dislikeCount).toHaveText(String(COUNTS.dislikes));
await expect(wrapper).toBeVisible();
await expect(container).toBeVisible();
await expect(bar).toBeVisible();
await expect(tooltip).toContainText("300 / 100");
await expect(tooltip).toHaveAttribute("role", "tooltip");
await expect(tooltip).toBeHidden();
await expect(page.locator("tp-yt-paper-tooltip#ryd-dislike-tooltip")).toHaveCount(0);
const idleTooltipStyle = await tooltip.evaluate((element) => {
const style = getComputedStyle(element);
return { opacity: style.opacity, visibility: style.visibility };
});
expect(idleTooltipStyle).toEqual({ opacity: "0", visibility: "hidden" });
await wrapper.hover({ position: { x: 1, y: 1 } });
await expect(tooltip).toBeVisible();
await expect.poll(() => tooltip.evaluate((element) => getComputedStyle(element).opacity)).toBe("1");
const hoveredTooltip = await tooltip.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
bottom: bounds.bottom,
color: style.color,
left: bounds.left,
opacity: style.opacity,
pointerEvents: style.pointerEvents,
position: style.position,
right: bounds.right,
visibility: style.visibility,
viewportWidth: innerWidth,
};
});
expect(hoveredTooltip).toMatchObject({
backgroundColor: "rgba(28, 28, 28, 0.96)",
color: "rgb(255, 255, 255)",
opacity: "1",
pointerEvents: "none",
position: "absolute",
visibility: "visible",
});
expect(hoveredTooltip.left).toBeGreaterThanOrEqual(0);
expect(hoveredTooltip.right).toBeLessThanOrEqual(hoveredTooltip.viewportWidth);
expect(hoveredTooltip.bottom).toBeGreaterThan(0);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await expect(tooltip).toBeHidden();
await wrapper.focus();
await expect(tooltip).toBeVisible();
await wrapper.evaluate((element) => element.blur());
await expect(tooltip).toBeHidden();
const geometry = await page.evaluate(() => {
const rect = (selector) => {
const bounds = document.querySelector(selector).getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
return {
bar: rect("#return-youtube-dislike-bar"),
container: rect("#return-youtube-dislike-bar-container"),
dislike: rect('[data-ryd-role="dislike"] button'),
like: rect('[data-ryd-role="like"] button'),
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(".ryd-tooltip"),
};
});
expect(geometry.container.width).toBeGreaterThan(0);
expect(geometry.container.height).toBeGreaterThan(0);
expect(geometry.bar.width).toBeGreaterThan(0);
expect(geometry.bar.height).toBeGreaterThan(0);
expect(geometry.bar.width / geometry.container.width).toBeCloseTo(0.75, 2);
expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
expect(geometry.container.left).toBeGreaterThanOrEqual(0);
expect(geometry.container.top).toBeGreaterThanOrEqual(0);
expect(geometry.container.right).toBeLessThanOrEqual(geometry.viewport.width);
expect(geometry.container.bottom).toBeLessThanOrEqual(geometry.viewport.height);
expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom));
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(15, 15, 15)",
negative: 139,
positive: "rgb(241, 241, 241)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
test("default ratio bar keeps both sides distinct in the light theme", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { theme: "light" });
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(255, 255, 255)",
negative: 123,
positive: "rgb(15, 15, 15)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
test("in-memory disabled option omits the ratio bar", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { rateBarEnabled: false });
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(0);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
@@ -0,0 +1,233 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const VIEWPORTS = [
{ height: 720, name: "wide", width: 1280 },
{ height: 720, name: "narrow", width: 768 },
{ height: 844, name: "mobile-sized", width: 390 },
];
async function launchShortsLayoutFixture({ context, page }, fixture = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ fixture });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
return { backend, pageErrors, syntheticDislike };
}
for (const viewport of VIEWPORTS) {
test(`synthetic desktop Shorts action matches the full native stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors } = await launchShortsLayoutFixture({ context, page });
const geometry = await page.evaluate(() => {
const nativeOuter = document.querySelector('[data-ryd-role="like"]');
const syntheticOuter = document.querySelector("[data-ryd-synthetic-shorts-dislike]");
const actionBar = syntheticOuter.parentElement;
const commentsOuter = document.querySelector('[data-fixture-control="comments"]');
const nativeLabel = nativeOuter.querySelector("label");
const syntheticLabel = syntheticOuter.querySelector("label");
const syntheticButton = syntheticOuter.querySelector("button");
const syntheticIconWrapper = syntheticButton.querySelector(".ytSpecButtonShapeNextIcon");
const syntheticIcon = syntheticIconWrapper.querySelector("svg");
const nativeCount = nativeOuter.querySelector("#text");
const syntheticCount = syntheticOuter.querySelector("#text");
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const boxStyle = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const textStyle = (element) => {
const style = getComputedStyle(element);
return { fontSize: style.fontSize, lineHeight: style.lineHeight };
};
return {
actionStack: [...actionBar.children].map((element) => ({
role:
element.getAttribute("data-fixture-control") ??
(element.matches("[data-ryd-synthetic-shorts-dislike]") ? "synthetic-dislike" : "like"),
...rect(element),
})),
commentsOuter: rect(commentsOuter),
nativeClass: nativeOuter.getAttribute("class"),
nativeCount: textStyle(nativeCount),
nativeLabel: rect(nativeLabel),
nativeOuter: rect(nativeOuter),
nativeOuterStyle: boxStyle(nativeOuter),
syntheticButton: rect(syntheticButton),
syntheticClass: syntheticOuter.getAttribute("class"),
syntheticCount: textStyle(syntheticCount),
syntheticIcon: rect(syntheticIcon),
syntheticIconWrapper: rect(syntheticIconWrapper),
syntheticLabel: rect(syntheticLabel),
syntheticOuter: rect(syntheticOuter),
syntheticOuterStyle: boxStyle(syntheticOuter),
};
});
expect(geometry.nativeClass).toBe("ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton");
expect(geometry.nativeOuter.width).toBe(48);
expect(geometry.nativeOuter.height).toBe(78);
expect(geometry.nativeOuterStyle.padding).toEqual(["0px", "0px", "8px", "0px"]);
expect(geometry.nativeLabel.width).toBe(48);
expect(geometry.nativeLabel.height).toBe(70);
expect(geometry.nativeCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticClass.split(/\s+/)).toEqual(
expect.arrayContaining([
"ytLikeButtonViewModelHost",
"ytwReelActionBarViewModelHostDesktopActionButton",
"ryd-synthetic-shorts-dislike",
]),
);
expect(geometry.syntheticClass).not.toContain("undefined");
expect(geometry.syntheticOuter.width).toBeCloseTo(geometry.nativeOuter.width, 5);
expect(geometry.syntheticOuter.height).toBeCloseTo(geometry.nativeOuter.height, 5);
expect(geometry.syntheticOuterStyle.padding).toEqual(geometry.nativeOuterStyle.padding);
expect(geometry.syntheticOuterStyle.margin).toEqual(geometry.nativeOuterStyle.margin);
expect(geometry.syntheticOuter.top).toBeCloseTo(geometry.nativeOuter.bottom, 5);
expect(geometry.syntheticButton.width).toBe(48);
expect(geometry.syntheticButton.height).toBe(48);
expect(geometry.syntheticLabel.width).toBe(48);
expect(geometry.syntheticLabel.height).toBe(70);
expect(geometry.syntheticIconWrapper.width).toBe(24);
expect(geometry.syntheticIconWrapper.height).toBe(24);
expect(geometry.syntheticIcon.width).toBe(24);
expect(geometry.syntheticIcon.height).toBe(24);
expect(geometry.syntheticIconWrapper.left + geometry.syntheticIconWrapper.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIconWrapper.top + geometry.syntheticIconWrapper.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticIcon.left + geometry.syntheticIcon.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIcon.top + geometry.syntheticIcon.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticCount).toEqual(geometry.nativeCount);
expect(geometry.commentsOuter.top).toBeCloseTo(geometry.syntheticOuter.bottom, 5);
expect(geometry.actionStack.map(({ role }) => role)).toEqual([
"like",
"synthetic-dislike",
"comments",
"share",
"remix",
]);
geometry.actionStack.forEach((control, index) => {
expect(control.width).toBeCloseTo(48, 5);
expect(control.height).toBeCloseTo(78, 5);
expect(control.left).toBeGreaterThanOrEqual(0);
expect(control.right).toBeLessThanOrEqual(viewport.width);
expect(control.top).toBeGreaterThanOrEqual(0);
expect(control.bottom).toBeLessThanOrEqual(viewport.height);
expect(control.left + control.width / 2).toBeCloseTo(
geometry.actionStack[0].left + geometry.actionStack[0].width / 2,
5,
);
if (index > 0) expect(control.top).toBeCloseTo(geometry.actionStack[index - 1].bottom, 5);
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
for (const viewport of VIEWPORTS) {
test(`Shorts selected and unselected states preserve the stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors, syntheticDislike } = await launchShortsLayoutFixture(
{ context, page },
{ initialState: "liked" },
);
const nativeLike = page.locator('[data-ryd-role="like"]:visible');
const syntheticButton = syntheticDislike.locator("button");
const readStackGeometry = () =>
page.locator("reel-action-bar-view-model:visible").evaluate((actionBar) =>
[...actionBar.children].map((element) => {
const bounds = element.getBoundingClientRect();
return { height: bounds.height, left: bounds.left, top: bounds.top, width: bounds.width };
}),
);
const initialGeometry = await readStackGeometry();
const neutralColor = await syntheticButton.evaluate((button) => getComputedStyle(button).color);
await expect(nativeLike).toHaveClass(/\bstyle-default-active\b/);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bytLikeButtonViewModelHost\b/);
await expect(syntheticDislike).toHaveClass(/\bytwReelActionBarViewModelHostDesktopActionButton\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bundefined\b/);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticButton).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-text\b/);
await expect
.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color))
.toBe("rgb(62, 166, 255)");
expect(await readStackGeometry()).toEqual(initialGeometry);
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveClass(/\bstyle-text\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color)).toBe(neutralColor);
expect(await readStackGeometry()).toEqual(initialGeometry);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
@@ -0,0 +1,426 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const BASE_COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide", width: 1280, height: 720 },
{ name: "narrow", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
const TRANSITIONS = [
{
action: "like",
initialState: "neutral",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "neutral",
nextState: "disliked",
value: -1,
likesDelta: 0,
dislikesDelta: 1,
},
{
action: "like",
initialState: "liked",
nextState: "neutral",
value: 0,
likesDelta: -1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "liked",
nextState: "disliked",
value: -1,
likesDelta: -1,
dislikesDelta: 1,
},
{
action: "like",
initialState: "disliked",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: -1,
},
{
action: "dislike",
initialState: "disliked",
nextState: "neutral",
value: 0,
likesDelta: 0,
dislikesDelta: -1,
},
];
function monitorRuntime(page) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
return { consoleErrors, pageErrors };
}
async function launchVisualFixture({ context, page }, initialState) {
const runtime = monitorRuntime(page);
const backend = createFakeBackend({
countsByVideo: { [VIDEO_A]: BASE_COUNTS },
fixture: { initialState },
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes));
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
return { backend, ...runtime };
}
async function readVisualContract(page) {
return page.evaluate(() => {
const required = (selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing visual-contract element: ${selector}`);
return element;
};
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const box = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const control = (role) => {
const outer = required(`[data-ryd-role="${role}"]`);
const button = required(`[data-ryd-role="${role}"] button`);
const icon = required(`[data-ryd-role="${role}"] [data-fixture-icon]`);
const text = required(`[data-ryd-role="${role}"] #text`);
const buttonStyle = getComputedStyle(button);
return {
ariaPressed: button.getAttribute("aria-pressed"),
backgroundColor: buttonStyle.backgroundColor,
button: rect(button),
buttonBox: box(button),
classes: [...outer.classList].sort(),
color: buttonStyle.color,
icon: rect(icon),
iconBox: box(icon),
outer: rect(outer),
outerBox: box(outer),
text: rect(text),
textBox: box(text),
};
};
const surface = required("#top-level-buttons-computed");
const reactionGroup = required('[data-ryd-role="buttons"]');
const topRow = required("#top-row");
const wrapper = required(".ryd-tooltip");
const barContainer = required("#return-youtube-dislike-bar-container");
const bar = required("#return-youtube-dislike-bar");
const tooltip = required("#ryd-dislike-tooltip");
const reactionGroupStyle = getComputedStyle(reactionGroup);
const surfaceStyle = getComputedStyle(surface);
const topRowStyle = getComputedStyle(topRow);
const barStyle = getComputedStyle(bar);
const barContainerStyle = getComputedStyle(barContainer);
const tooltipStyle = getComputedStyle(tooltip);
const wrapperStyle = getComputedStyle(wrapper);
return {
bar: rect(bar),
barAppearance: {
backgroundColor: barStyle.backgroundColor,
borderRadius: barStyle.borderRadius,
},
barContainer: rect(barContainer),
barContainerAppearance: {
backgroundColor: barContainerStyle.backgroundColor,
borderRadius: barContainerStyle.borderRadius,
},
buttons: rect(surface),
buttonsBox: box(surface),
buttonsGap: reactionGroupStyle.gap,
buttonsPosition: surfaceStyle.position,
dislike: control("dislike"),
like: control("like"),
ownership: {
barOwnedByButtons: surface.contains(barContainer),
tooltipDescribedBy: wrapper.getAttribute("aria-describedby"),
tooltipOwnedByButtons: surface.contains(tooltip),
tooltipRole: tooltip.getAttribute("role"),
wrapperOwnedByButtons: wrapper.parentElement === surface,
},
reactionGroup: rect(reactionGroup),
reactionGroupBox: box(reactionGroup),
topRow: rect(topRow),
topRowBox: box(topRow),
topRowStyle: {
borderBottomWidth: topRowStyle.borderBottomWidth,
paddingBottom: topRowStyle.paddingBottom,
},
tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(),
tooltipAppearance: {
fontSize: tooltipStyle.fontSize,
lineHeight: tooltipStyle.lineHeight,
opacity: tooltipStyle.opacity,
padding: box(tooltip).padding,
position: tooltipStyle.position,
visibility: tooltipStyle.visibility,
},
unique: {
bars: document.querySelectorAll("#return-youtube-dislike-bar").length,
containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length,
tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length,
wrappers: document.querySelectorAll(".ryd-tooltip").length,
},
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(wrapper),
wrapperAppearance: {
box: box(wrapper),
position: wrapperStyle.position,
},
};
});
}
function expectClose(actual, expected, precision = 5) {
expect(actual).toBeCloseTo(expected, precision);
}
function expectComputedColor(actual, expectedChannel) {
const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
const channels = srgb
? srgb.slice(1).map((channel) => Number(channel) * 255)
: actual
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
expect(channels).toHaveLength(3);
channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0));
}
function expectControlGeometry(control) {
expectClose(control.outer.width, 96);
expectClose(control.outer.height, 36);
expectClose(control.button.width, 96);
expectClose(control.button.height, 36);
expect(control.outerBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.buttonBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "12px", "0px", "12px"],
});
expect(control.iconBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.textBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expectClose(control.icon.width, 20);
expectClose(control.icon.height, 20);
expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.left - control.icon.right, 6);
}
function expectPressedState(control, pressed) {
expect(control.ariaPressed).toBe(String(pressed));
expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]);
expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)");
expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)");
}
function expectVisualState(snapshot, state, counts, viewport) {
expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width });
expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 });
expect(snapshot.ownership).toEqual({
barOwnedByButtons: true,
tooltipDescribedBy: "ryd-dislike-tooltip",
tooltipOwnedByButtons: true,
tooltipRole: "tooltip",
wrapperOwnedByButtons: true,
});
expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`);
expect(snapshot.buttonsGap).toBe("0px");
expect(snapshot.buttonsPosition).toBe("relative");
expect(snapshot.buttonsBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox);
expect(snapshot.topRowBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "10px", "0px"],
});
expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" });
expect(snapshot.wrapperAppearance).toEqual({
box: {
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
},
position: "absolute",
});
expect(snapshot.barContainerAppearance.borderRadius).toBe("2px");
expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139);
expect(snapshot.barAppearance).toEqual({
backgroundColor: "rgb(241, 241, 241)",
borderRadius: "2px",
});
expect(snapshot.tooltipAppearance).toEqual({
fontSize: "12px",
lineHeight: "16px",
opacity: "0",
padding: ["6px", "8px", "6px", "8px"],
position: "absolute",
visibility: "hidden",
});
expectControlGeometry(snapshot.like);
expectControlGeometry(snapshot.dislike);
expectPressedState(snapshot.like, state === "liked");
expectPressedState(snapshot.dislike, state === "disliked");
expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left);
expectClose(snapshot.wrapper.left, snapshot.like.outer.left);
expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right);
expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width);
expectClose(snapshot.wrapper.height, 2);
expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8);
expectClose(snapshot.barContainer.width, snapshot.wrapper.width);
expectClose(snapshot.barContainer.height, 2);
expectClose(snapshot.bar.height, 2);
expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3);
expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0);
expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width);
expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left);
expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right);
}
function expectNoStructuralLayoutShift(before, after) {
const paths = [
["topRow"],
["buttons"],
["reactionGroup"],
["wrapper"],
["barContainer"],
["like", "outer"],
["like", "button"],
["dislike", "outer"],
["dislike", "button"],
];
const dimensions = ["top", "right", "bottom", "left", "width", "height"];
for (const path of paths) {
const beforeRect = path.reduce((value, key) => value[key], before);
const afterRect = path.reduce((value, key) => value[key], after);
for (const dimension of dimensions) {
expect(
Math.abs(afterRect[dimension] - beforeRect[dimension]),
`${path.join(".")}.${dimension}`,
).toBeLessThanOrEqual(0.25);
}
}
}
async function waitForBarRatio(page, counts) {
const expectedRatio = counts.likes / (counts.likes + counts.dislikes);
await expect
.poll(() =>
page.locator("#return-youtube-dislike-bar").evaluate((bar) => {
const container = bar.parentElement;
return bar.getBoundingClientRect().width / container.getBoundingClientRect().width;
}),
)
.toBeCloseTo(expectedRatio, 3);
}
async function attachFailureScreenshot(testInfo, page, name) {
let body;
try {
const scope = page.locator("#top-row");
body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot();
} catch {
body = await page.screenshot().catch(() => null);
}
if (body) {
await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" });
}
}
for (const viewport of VIEWPORTS) {
test.describe(`${viewport.name} watch visual contract`, () => {
for (const transition of TRANSITIONS) {
const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`;
test(name, async ({ context, page }, testInfo) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
try {
const harness = await launchVisualFixture({ context, page }, transition.initialState);
const before = await readVisualContract(page);
expectVisualState(before, transition.initialState, BASE_COUNTS, viewport);
await page.locator(`[data-ryd-role="${transition.action}"] button`).click();
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
const nextCounts = {
likes: BASE_COUNTS.likes + transition.likesDelta,
dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta,
};
await waitForBarRatio(page, nextCounts);
const after = await readVisualContract(page);
expectVisualState(after, transition.nextState, nextCounts, viewport);
expectNoStructuralLayoutShift(before, after);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
value: transition.value,
videoId: VIDEO_A,
});
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
} catch (error) {
await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`);
throw error;
}
});
}
});
}
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
async function prepareGuardedFixture({ context, page }, openFixture) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend();
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openFixture(page, VIDEO_A);
await forbidUnsafeHtmlSinks(page);
await injectGeneratedUserscript(page);
return { backend, pageErrors };
}
async function expectNoUnsafeHtmlUsage(page, backend, pageErrors) {
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
}
test("watch ratio bar initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openWatchFixture);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText("25");
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
await expect(page.locator("#return-youtube-dislike-bar")).toBeVisible();
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 25");
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
test("modern Shorts synthetic dislike initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openShortsFixture);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]");
await expect
.poll(() =>
page.evaluate(() => ({
initialized: Boolean(document.querySelector("[data-ryd-synthetic-shorts-dislike]")),
unsafeHtmlSinkCalls: globalThis.__rydUnsafeHtmlSinkCalls,
})),
)
.toEqual({ initialized: true, unsafeHtmlSinkCalls: [] });
await expect(syntheticDislike).toBeVisible();
await expect(syntheticDislike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
await expect(syntheticDislike.locator("svg path")).toHaveCount(1);
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
/**
* @jest-environment jsdom
*/
const path = require("node:path");
const {
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
readLivePageState,
runLoggedStage,
} = require("../e2e/live/live-diagnostics");
class FakeEmitter {
constructor() {
this.listeners = new Map();
}
emit(event, value) {
for (const listener of this.listeners.get(event) ?? []) listener(value);
}
off(event, listener) {
this.listeners.get(event)?.delete(listener);
}
on(event, listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(listener);
}
}
function visibleRect() {
return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 };
}
describe("live interactive diagnostics", () => {
test("captures runtime and Shorts ownership state without dumping page HTML", () => {
document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="abcdefghijk" is-active>
<a href="/shorts/abcdefghijk">Short</a>
<reel-action-bar-view-model>
<like-button-view-model></like-button-view-model>
<button-view-model data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk">
<button aria-pressed="false"></button><span>123</span>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
for (const element of document.querySelectorAll("*")) element.getBoundingClientRect = visibleRect;
const state = readLivePageState();
expect(state.runtimeMarkers).toEqual({
extension: null,
extensionBuild: null,
userscript: "3.2.0",
userscriptBuild: null,
});
expect(state.renderers).toEqual([
expect.objectContaining({ actionBars: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.renderers[0].links).toEqual([{ href: "/shorts/abcdefghijk", visible: true }]);
expect(state.actionBars).toEqual([
expect.objectContaining({ nativeLikes: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.syntheticControls).toEqual([
expect.objectContaining({ ariaPressed: "false", text: "123", videoId: "abcdefghijk", visible: true }),
]);
expect(JSON.stringify(state)).not.toContain("outerHTML");
});
test("records browser failures and redacted recent API traffic in the persisted snapshot", async () => {
const context = new FakeEmitter();
const page = new FakeEmitter();
page.addInitScript = jest.fn().mockResolvedValue(undefined);
page.evaluate = jest.fn(async (callback) =>
callback.name === "readLivePageState" ? { runtimeMarkers: { userscript: "3.2.0" } } : undefined,
);
page.isClosed = jest.fn(() => false);
page.url = jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk");
const fileSystem = { mkdirSync: jest.fn(), writeFileSync: jest.fn() };
const log = jest.fn();
const diagnostics = new LiveRunDiagnostics(page, context, {
clock: () => new Date("2026-08-18T12:34:56.000Z"),
fileSystem,
log,
outputDirectory: "diagnostics",
runtime: "userscript",
});
await diagnostics.start();
diagnostics.stageStarted("read-only.channel-to-shorts-and-next");
diagnostics.checkpoint("ryd-votes-response.waiting", { videoId: "abcdefghijk" });
const request = {
method: () => "GET",
resourceType: () => "fetch",
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&userId=private-id",
};
context.emit("request", request);
context.emit("response", { request: () => request, status: () => 200 });
page.emit("pageerror", new Error("page exploded"));
page.emit("console", {
location: () => ({ lineNumber: 7, url: "https://www.youtube.com/shorts/abcdefghijk" }),
text: () => `${UNHANDLED_REJECTION_PREFIX}promise exploded`,
type: () => "error",
});
page.emit("console", {
location: () => ({}),
text: () => "console exploded",
type: () => "error",
});
const snapshotPath = await diagnostics.persistFailureSnapshot(new Error("scenario timed out"));
diagnostics.stop();
expect(snapshotPath).toBe(path.join("diagnostics", "failure-2026-08-18T12-34-56-000Z.json"));
expect(fileSystem.mkdirSync).toHaveBeenCalledWith("diagnostics", { recursive: true });
const snapshot = JSON.parse(fileSystem.writeFileSync.mock.calls[0][1]);
expect(snapshot.currentStage).toBe("read-only.channel-to-shorts-and-next");
expect(snapshot.currentCheckpoint).toBe("ryd-votes-response.waiting");
expect(snapshot.browserSignals.map(({ type }) => type)).toEqual([
"pageerror",
"unhandledrejection",
"console.error",
]);
expect(snapshot.recentApiRequests).toEqual([
expect.objectContaining({
method: "GET",
pathname: "/votes",
query: { userId: "<redacted>", videoId: "abcdefghijk" },
status: 200,
}),
]);
expect(snapshot.pageState).toEqual({ runtimeMarkers: { userscript: "3.2.0" } });
expect(snapshot.url).toBe("https://www.youtube.com/shorts/abcdefghijk");
expect(context.listeners.get("request").size).toBe(0);
expect(page.listeners.get("pageerror").size).toBe(0);
});
test("logs stage completion and failure boundaries", async () => {
const diagnostics = {
stageCompleted: jest.fn(),
stageFailed: jest.fn(),
stageStarted: jest.fn(),
};
await expect(runLoggedStage(diagnostics, "success", async () => 42)).resolves.toBe(42);
const failure = new Error("failed");
await expect(
runLoggedStage(diagnostics, "failure", async () => {
throw failure;
}),
).rejects.toBe(failure);
expect(diagnostics.stageStarted.mock.calls.map(([name]) => name)).toEqual(["success", "failure"]);
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("success", expect.any(Number));
expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure);
});
test("redacts identity and proof material while retaining diagnostic video IDs", () => {
expect(
diagnosticApiUrl(
"https://returnyoutubedislikeapi.com/puzzle/registration?userId=private&videoId=abcdefghijk&solution=secret",
),
).toEqual({
pathname: "/puzzle/registration",
query: { solution: "<redacted>", userId: "<redacted>", videoId: "abcdefghijk" },
});
});
});
+237
View File
@@ -0,0 +1,237 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const extensionPackage = require("../../../package.json");
const userscriptVersion = require("../userscript-version.json");
const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120;
const LIVE_VOTE_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals");
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@SmashTrash";
const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4";
const DEFAULT_LIVE_SIDEBAR_HOPS = 3;
const MAX_LIVE_SIDEBAR_HOPS = 10;
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
const SUPPORTED_RUNTIMES = new Set(["userscript", "extension"]);
const LIVE_BUILD_MARKER_PATHS = {
extension: path.resolve(__dirname, "../../combined/dist/chrome/live-build.json"),
userscript: path.resolve(__dirname, "../../../test-results/live-build/userscript/live-build.json"),
};
function requireValue(environment, name) {
const value = environment[name]?.trim();
if (!value) throw new Error(`${name} is required for the live YouTube smoke suite.`);
return value;
}
function requireVideoId(environment, name) {
const value = requireValue(environment, name);
if (!VIDEO_ID_PATTERN.test(value)) {
throw new Error(`${name} must be an 11-character YouTube video ID.`);
}
return value;
}
function optionalVideoId(environment, name) {
const value = environment[name]?.trim();
if (!value) return null;
if (!VIDEO_ID_PATTERN.test(value)) {
throw new Error(`${name} must be an 11-character YouTube video ID when provided.`);
}
return value;
}
function optionalBoundedInteger(environment, name, defaultValue, maximum) {
const rawValue = environment[name]?.trim();
if (!rawValue) return defaultValue;
if (!/^\d+$/.test(rawValue)) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
}
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
}
return value;
}
function requireChannelHandle(environment) {
const value = requireValue(environment, "RYD_LIVE_EXPECTED_CHANNEL");
if (!/^@[A-Za-z0-9._-]{3,100}$/.test(value)) {
throw new Error("RYD_LIVE_EXPECTED_CHANNEL must be the public @handle of the signed-in YouTube test channel.");
}
return value;
}
function liveVoteApproval(runtime, videoId, unixSeconds) {
return `${runtime}:${videoId}:${unixSeconds}`;
}
function hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds) {
if (!value) return false;
const [approvedRuntime, approvedVideoId, approvedAt, ...rest] = value.split(":");
if (rest.length || approvedRuntime !== runtime || approvedVideoId !== videoId) return false;
const approvedAtSeconds = Number(approvedAt);
if (!Number.isSafeInteger(approvedAtSeconds)) return false;
const ageSeconds = Math.floor(nowMilliseconds / 1000) - approvedAtSeconds;
return ageSeconds >= 0 && ageSeconds <= LIVE_VOTE_APPROVAL_WINDOW_SECONDS;
}
function consumeLiveVoteApproval(
value,
runtime,
videoId,
{ nowMilliseconds = Date.now(), usedApprovalsDirectory } = {},
) {
if (!hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds)) return false;
const directory = usedApprovalsDirectory || LIVE_VOTE_APPROVALS_DIRECTORY;
fs.mkdirSync(directory, { recursive: true });
const approvalHash = crypto.createHash("sha256").update(value).digest("hex");
try {
fs.writeFileSync(path.join(directory, approvalHash), `${runtime}:${videoId}\n`, { flag: "wx" });
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
throw error;
}
}
function parsePlaylistUrl(value, watchVideoId) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("RYD_LIVE_PLAYLIST_URL must be a valid HTTPS YouTube watch URL.");
}
if (
url.protocol !== "https:" ||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
url.pathname !== "/watch" ||
url.searchParams.get("v") !== watchVideoId ||
!url.searchParams.get("list")
) {
throw new Error(
"RYD_LIVE_PLAYLIST_URL must be an HTTPS YouTube watch URL for RYD_LIVE_WATCH_A with a playlist ID.",
);
}
return url.toString();
}
function parseChannelUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("RYD_LIVE_NAV_CHANNEL_URL must be a valid HTTPS YouTube channel URL.");
}
const isSafeChannelPath = /^\/@[A-Za-z0-9._-]{3,100}(?:\/(?:featured|shorts|videos))?\/?$/.test(url.pathname);
if (
url.protocol !== "https:" ||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
url.port ||
url.username ||
url.password ||
url.search ||
url.hash ||
!isSafeChannelPath
) {
throw new Error(
"RYD_LIVE_NAV_CHANNEL_URL must be a plain HTTPS youtube.com /@handle channel, featured, Shorts, or videos URL.",
);
}
url.hostname = "www.youtube.com";
return url.toString();
}
function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, readFileSync = fs.readFileSync } = {}) {
const markerPath = markerPaths[runtime];
let marker;
try {
marker = JSON.parse(readFileSync(markerPath, "utf8"));
} catch (error) {
throw new Error(
`Cannot read the ${runtime} live-build marker at ${markerPath}. Build that live artifact before running the smoke.`,
{ cause: error },
);
}
if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) {
throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`);
}
return marker.buildId;
}
function readLiveOptions(
environment = process.env,
nowMilliseconds = Date.now(),
{ readBuildId = readExpectedBuildId } = {},
) {
if (environment.RYD_LIVE_YOUTUBE !== "1") return null;
if (environment.RYD_LIVE_PRODUCTION_API !== "1") {
throw new Error(
"RYD_LIVE_PRODUCTION_API=1 is required because the installed runtime will contact the production RYD API.",
);
}
const runtime = requireValue(environment, "RYD_LIVE_RUNTIME");
if (!SUPPORTED_RUNTIMES.has(runtime)) {
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension".');
}
const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A");
const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B");
const short = requireVideoId(environment, "RYD_LIVE_SHORT");
if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos.");
const expectedVersion =
environment.RYD_LIVE_EXPECTED_VERSION?.trim() ||
(runtime === "userscript" ? userscriptVersion : extensionPackage.version);
return {
cdpEndpoint: environment.RYD_CDP_ENDPOINT?.trim() || "chrome",
expectedBuildId: readBuildId(runtime),
expectedChannel: requireChannelHandle(environment),
expectedVersion,
navigation: {
channelUrl: parseChannelUrl(environment.RYD_LIVE_NAV_CHANNEL_URL?.trim() || DEFAULT_LIVE_NAV_CHANNEL_URL),
short: requireVideoId(
{ RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT },
"RYD_LIVE_NAV_SHORT",
),
watch: optionalVideoId(environment, "RYD_LIVE_NAV_WATCH"),
},
playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA),
productionApiApproved: true,
runtime,
sidebar: {
hopCount: optionalBoundedInteger(
environment,
"RYD_LIVE_SIDEBAR_HOPS",
DEFAULT_LIVE_SIDEBAR_HOPS,
MAX_LIVE_SIDEBAR_HOPS,
),
},
short,
watchA,
watchB,
};
}
module.exports = {
DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS,
LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
consumeLiveVoteApproval,
hasFreshVoteApproval,
liveVoteApproval,
readExpectedBuildId,
readLiveOptions,
};
@@ -0,0 +1,223 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS,
LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
consumeLiveVoteApproval,
hasFreshVoteApproval,
liveVoteApproval,
readExpectedBuildId,
readLiveOptions: readLiveOptionsFromEnvironment,
} = require("./live-options");
const NOW = Date.parse("2026-08-08T12:00:00.000Z");
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
const VALID_ENVIRONMENT = {
RYD_LIVE_YOUTUBE: "1",
RYD_LIVE_PRODUCTION_API: "1",
RYD_LIVE_RUNTIME: "userscript",
RYD_LIVE_WATCH_A: "abcdefghijk",
RYD_LIVE_WATCH_B: "zyxwvutsrqp",
RYD_LIVE_SHORT: "shortsabcde",
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test",
RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test",
};
function readLiveOptions(environment, nowMilliseconds = NOW) {
return readLiveOptionsFromEnvironment(environment, nowMilliseconds, {
readBuildId: () => EXPECTED_BUILD_ID,
});
}
describe("live YouTube options", () => {
test("reads the exact generated build ID from the selected runtime marker", () => {
const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID }));
expect(
readExpectedBuildId("userscript", {
markerPaths: { userscript: "owned-live-build.json" },
readFileSync,
}),
).toBe(EXPECTED_BUILD_ID);
expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8");
});
test.each([
[
"missing",
() => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
/Cannot read/,
],
["invalid JSON", () => "not JSON", /Cannot read/],
["malformed ID", () => JSON.stringify({ buildId: "stale" }), /is malformed/],
])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => {
expect(() =>
readExpectedBuildId("extension", {
markerPaths: { extension: "owned-live-build.json" },
readFileSync,
}),
).toThrow(expectedMessage);
});
test("stores consumed vote approvals outside Playwright's cleaned output directory", () => {
const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube");
expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory);
expect(LIVE_VOTE_APPROVALS_DIRECTORY.startsWith(`${playwrightOutputDirectory}${path.sep}`)).toBe(false);
});
test("stays disabled unless explicitly opted in", () => {
expect(readLiveOptions({})).toBeNull();
});
test("requires production API acknowledgement", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow(
"RYD_LIVE_PRODUCTION_API=1",
);
});
test.each(["", "tampermonkey", "chrome"])("rejects unsupported runtime %p", (runtime) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toThrow(/RYD_LIVE_RUNTIME/);
});
test("requires distinct valid video IDs", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORT: "too-short" })).toThrow(
"11-character YouTube video ID",
);
expect(() =>
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_WATCH_B: VALID_ENVIRONMENT.RYD_LIVE_WATCH_A,
}),
).toThrow("must be different videos");
});
test("requires a playlist URL anchored at watch A", () => {
expect(() =>
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=zyxwvutsrqp&list=PL-test",
}),
).toThrow("for RYD_LIVE_WATCH_A");
});
test("requires the expected signed-in channel handle", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_CHANNEL: "not-a-handle" })).toThrow(
"public @handle",
);
});
test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({
cdpEndpoint: "chrome",
expectedBuildId: EXPECTED_BUILD_ID,
navigation: {
channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL,
short: DEFAULT_LIVE_NAV_SHORT,
watch: null,
},
runtime,
sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS },
short: "shortsabcde",
watchA: "abcdefghijk",
watchB: "zyxwvutsrqp",
});
});
test("takes the exact build ID from the generated marker and ignores environment attempts to bless a stale build", () => {
const readBuildId = jest.fn(() => EXPECTED_BUILD_ID);
const result = readLiveOptionsFromEnvironment(
{ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) },
NOW,
{ readBuildId },
);
expect(readBuildId).toHaveBeenCalledWith("userscript");
expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID);
});
test("accepts a bounded sidebar stress hop count", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({
sidebar: { hopCount: 5 },
});
});
test.each(["0", "11", "1.5", "three", "-1"])("rejects invalid sidebar stress hop count %p", (hopCount) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow(
/RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/,
);
});
test("accepts an exact, safely scoped channel-navigation dataset", () => {
expect(
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_NAV_CHANNEL_URL: "https://youtube.com/@ryd-test/shorts",
RYD_LIVE_NAV_SHORT: "navshort001",
RYD_LIVE_NAV_WATCH: "navwatch001",
}),
).toMatchObject({
navigation: {
channelUrl: "https://www.youtube.com/@ryd-test/shorts",
short: "navshort001",
watch: "navwatch001",
},
});
});
test.each([
"http://www.youtube.com/@SmashTrash",
"https://example.com/@SmashTrash",
"https://www.youtube.com.evil.example/@SmashTrash",
"https://www.youtube.com/channel/UC-not-a-handle",
"https://www.youtube.com/@SmashTrash/playlists",
"https://www.youtube.com/@SmashTrash?app=desktop",
])("rejects unsafe or non-deterministic navigation channel URL %p", (channelUrl) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_NAV_CHANNEL_URL: channelUrl })).toThrow(
/RYD_LIVE_NAV_CHANNEL_URL/,
);
});
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates optional navigation ID %s", (name) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID");
});
test("accepts only a fresh runtime-and-video-specific vote approval", () => {
const nowSeconds = Math.floor(NOW / 1000);
const validApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds);
expect(hasFreshVoteApproval(validApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(true);
for (const approval of [
liveVoteApproval("extension", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds),
liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_A, nowSeconds),
liveVoteApproval(
"userscript",
VALID_ENVIRONMENT.RYD_LIVE_WATCH_B,
nowSeconds - LIVE_VOTE_APPROVAL_WINDOW_SECONDS - 1,
),
]) {
expect(hasFreshVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
}
const futureApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds + 1);
expect(hasFreshVoteApproval(futureApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
});
test("consumes a fresh vote approval only once", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-approval-"));
const approval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, Math.floor(NOW / 1000));
try {
const settings = { nowMilliseconds: NOW, usedApprovalsDirectory: directory };
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(true);
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,119 @@
const { LiveYoutubeDriver } = require("../e2e/live/live-youtube-driver");
class FakeBrowserContext {
constructor() {
this.listeners = new Map();
this.routes = [];
this.routeCalls = 0;
this.unrouteCalls = 0;
}
on(event, handler) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(handler);
}
off(event, handler) {
this.listeners.get(event)?.delete(handler);
}
async route(matcher, handler) {
this.routeCalls += 1;
this.routes.push({ handler, matcher });
}
async unroute(matcher, handler) {
this.unrouteCalls += 1;
this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler);
}
async dispatch(request) {
for (const listener of this.listeners.get("request") ?? []) listener(request);
const route = {
abort: jest.fn().mockResolvedValue(undefined),
fallback: jest.fn().mockResolvedValue(undefined),
request: () => request,
};
for (const registered of [...this.routes].reverse()) {
if (registered.matcher(new URL(request.url()))) {
await registered.handler(route);
break;
}
}
return route;
}
}
function createDriver(context) {
const page = {
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
return new LiveYoutubeDriver(page, context);
}
function request(method, url) {
return {
frame: jest.fn(() => {
throw new Error("Service-worker requests do not have a frame");
}),
method: () => method,
url: () => url,
};
}
describe("live read-only production-interaction guard", () => {
test("installs the route first, aborts a frame-less interaction POST, and reports the attempt", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/vote?source=service-worker");
let interceptedRoute;
await expect(
driver.withNoProductionInteractions(async () => {
expect(context.routeCalls).toBe(1);
interceptedRoute = await context.dispatch(frameLessRequest);
}),
).rejects.toThrow("attempted a production interaction");
expect(interceptedRoute.abort).toHaveBeenCalledWith("blockedbyclient");
expect(interceptedRoute.fallback).not.toHaveBeenCalled();
expect(frameLessRequest.frame).not.toHaveBeenCalled();
expect(context.unrouteCalls).toBe(1);
expect(context.routes).toEqual([]);
});
test("shares one deny route across nested read-only guards", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/confirmVote");
await expect(
driver.withNoProductionInteractions(() =>
driver.withNoProductionInteractions(async () => {
await context.dispatch(frameLessRequest);
}),
),
).rejects.toThrow("attempted a production interaction");
expect(context.routeCalls).toBe(1);
expect(context.unrouteCalls).toBe(1);
expect(frameLessRequest.frame).not.toHaveBeenCalled();
});
test("allows non-interaction traffic and removes the route after success", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const result = await driver.withNoProductionInteractions(async () => {
const unrelatedRoute = await context.dispatch(request("POST", "https://www.youtube.com/youtubei/v1/player"));
expect(unrelatedRoute.abort).not.toHaveBeenCalled();
return "complete";
});
expect(result).toBe("complete");
expect(context.routeCalls).toBe(1);
expect(context.unrouteCalls).toBe(1);
expect(context.routes).toEqual([]);
});
});
@@ -0,0 +1,649 @@
const path = require("node:path");
const {
RESPONSIVE_VIEWPORTS,
runChannelShortsNavigationScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runResponsiveVisualScenario,
runSidebarStressScenario,
} = require("../e2e/live/live-scenarios");
const EXPECTED_ACTIONS = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
function nextState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked";
return state === "disliked" ? "neutral" : "disliked";
}
function valueForState(state) {
if (state === "liked") return 1;
if (state === "disliked") return -1;
return 0;
}
function createReactionHarness(
initialState,
{
clickThrowTransition = null,
failHandshake = null,
failHandshakes = failHandshake === null ? [] : [failHandshake],
failedVoteUserId = "shared-user-id",
rollbackOnClickThrow = false,
rollbackOnHandshakeFailure = false,
rollbackOnStateWaitFailure = false,
stateWaitFailTransition = null,
} = {},
) {
const events = [];
let currentState = initialState;
let currentVideo = null;
let handshakeNumber = 0;
let stateWaitNumber = 0;
const failedHandshakeNumbers = new Set(failHandshakes);
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
clickAction: jest.fn(async (videoId, action) => {
expect(currentVideo).toBe(videoId);
currentState = nextState(currentState, action);
events.push({ action, state: currentState });
if (events.length === clickThrowTransition) {
if (rollbackOnClickThrow) currentState = initialState;
throw new Error("simulated post-dispatch click failure");
}
}),
openShort: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
openWatch: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
readReactionState: jest.fn(async () => currentState),
waitForDislikeText: jest.fn(async () => "123"),
waitForReactionState: jest.fn(async (expected) => {
stateWaitNumber += 1;
if (stateWaitNumber === stateWaitFailTransition) {
if (rollbackOnStateWaitFailure) currentState = initialState;
throw new Error("simulated post-click state-wait failure");
}
expect(currentState).toBe(expected);
}),
};
const recorder = {
mark: jest.fn(() => events.length),
waitForHandshake: jest.fn(async (value) => {
handshakeNumber += 1;
expect(value).toBe(valueForState(currentState));
if (failedHandshakeNumbers.has(handshakeNumber)) {
if (rollbackOnHandshakeFailure) currentState = initialState;
throw new Error("simulated handshake failure");
}
return "shared-user-id";
}),
voteUserId: jest.fn(() => failedVoteUserId),
};
return {
driver,
events,
getState: () => currentState,
recorder,
};
}
const OPTIONS = {
expectedBuildId: "0123456789abcdef0123456789abcdef",
expectedChannel: "@ryd-test",
expectedVersion: "3.2.0",
runtime: "userscript",
};
describe("live Shorts navigation stabilization", () => {
test("pauses the next Short only after its current control has rendered", async () => {
const events = [];
const driver = {
assertCurrentShortsControl: jest.fn(async (videoId) => {
events.push(`control:${videoId}`);
return { count: videoId === "abcdefghijk" ? "100" : "200", synthetic: true, videoId };
}),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
navigateFromColdChannelToShort: jest.fn(async () => events.push("channel-navigation")),
navigateToNextShort: jest.fn(async () => {
events.push("next-navigation");
return "lmnopqrstuv";
}),
pausePlayback: jest.fn(async () => events.push("pause")),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const options = {
...OPTIONS,
navigation: {
channelUrl: "https://www.youtube.com/@SmashTrash",
short: "abcdefghijk",
},
};
await expect(runChannelShortsNavigationScenario(driver, options)).resolves.toEqual({
initial: { count: "100", synthetic: true, videoId: "abcdefghijk" },
next: { count: "200", synthetic: true, videoId: "lmnopqrstuv" },
});
expect(events).toEqual([
"channel-navigation",
"control:abcdefghijk",
"next-navigation",
"control:lmnopqrstuv",
"pause",
]);
expect(driver.pausePlayback).toHaveBeenCalledTimes(1);
});
});
describe("live watch sidebar stress scenario", () => {
test.each(["userscript", "extension"])(
"takes consecutive unvisited related links and soaks one current %s bar after each exact response",
async (runtime) => {
const hopVideoIds = ["hopvideo001", "hopvideo002", "hopvideo003"];
const counts = ["101", "202", "303"];
let currentVideoId = null;
let hopIndex = 0;
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideoId).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
count: counts[hopIndex - 1],
screenshotPath,
})),
navigateToRelatedWatch: jest.fn(async (excludedVideoIds) => {
expect(excludedVideoIds).toEqual(["abcdefghijk", ...hopVideoIds.slice(0, hopIndex)]);
const videoId = hopVideoIds[hopIndex];
const dislikes = (hopIndex + 1) * 1_000;
hopIndex += 1;
currentVideoId = videoId;
return { body: { dislikes }, videoId };
}),
openWatch: jest.fn(async (videoId) => {
currentVideoId = videoId;
}),
soakWatchRatioVisual: jest.fn(async (_runtime, settings) => ({
count: settings.expectedCount,
durationMs: settings.durationMs,
sampleCount: 9,
videoId: settings.videoId,
})),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const makeDirectory = jest.fn();
const options = { ...OPTIONS, runtime, sidebar: { hopCount: 3 }, watchA: "abcdefghijk" };
const result = await runSidebarStressScenario(driver, options, {
makeDirectory,
outputDirectory: "sidebar-evidence",
soakDurationMs: 25,
});
expect(makeDirectory).toHaveBeenCalledWith("sidebar-evidence");
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
expect(driver.navigateToRelatedWatch).toHaveBeenCalledTimes(3);
expect(driver.captureWatchRatioVisual.mock.calls).toEqual(
hopVideoIds.map((_videoId, index) => [
runtime,
path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
{ presenceTimeoutMs: 1_000 },
]),
);
expect(driver.soakWatchRatioVisual.mock.calls).toEqual(
hopVideoIds.map((videoId, index) => [runtime, { durationMs: 25, expectedCount: counts[index], videoId }]),
);
expect(driver.assertRuntime).toHaveBeenCalledTimes(4);
expect(driver.assertSignedIn).toHaveBeenCalledTimes(4);
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
expect(result).toEqual({
hopCount: 3,
hops: hopVideoIds.map((videoId, index) => ({
apiDislikes: (index + 1) * 1_000,
count: counts[index],
readyLatencyMs: expect.any(Number),
readyTimeoutMs: 1_000,
screenshotPath: path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
soak: {
count: counts[index],
durationMs: 25,
sampleCount: 9,
videoId,
},
videoId,
})),
outputDirectory: "sidebar-evidence",
startVideoId: options.watchA,
});
},
);
});
describe("live reaction cycle", () => {
test.each(["neutral", "liked", "disliked"])(
"covers all six transitions and restores an initially %s watch video",
async (initialState) => {
const harness = createReactionHarness(initialState);
const beforeFirstAction = jest.fn();
const result = await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction,
videoId: "abcdefghijk",
});
expect(harness.driver.openWatch).toHaveBeenCalledWith("abcdefghijk");
expect(harness.driver.openShort).not.toHaveBeenCalled();
expect(harness.events.map(({ action }) => action)).toEqual(EXPECTED_ACTIONS[initialState]);
expect(
new Set(
harness.events.map(
({ action, state }, index) =>
`${index ? harness.events[index - 1].state : initialState}:${action}:${state}`,
),
).size,
).toBe(6);
expect(harness.getState()).toBe(initialState);
expect(harness.recorder.waitForHandshake).toHaveBeenCalledTimes(6);
expect(beforeFirstAction).toHaveBeenCalledTimes(1);
expect(result).toEqual({ evidencePaths: [], initialState, userId: "shared-user-id" });
},
);
test("targets Shorts through the same transition implementation", async () => {
const harness = createReactionHarness("neutral");
await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
isShort: true,
videoId: "shortsabcde",
});
expect(harness.driver.openShort).toHaveBeenCalledWith("shortsabcde");
expect(harness.driver.openWatch).not.toHaveBeenCalled();
});
test("restores the initial state when a production handshake fails mid-cycle", async () => {
const harness = createReactionHarness("neutral", { failHandshake: 3 });
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated handshake failure");
expect(harness.getState()).toBe("neutral");
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
});
test.each([
[
"click dispatch throws",
{ clickThrowTransition: 1, rollbackOnClickThrow: true },
"simulated post-dispatch click failure",
],
[
"post-click state wait fails",
{ rollbackOnStateWaitFailure: true, stateWaitFailTransition: 1 },
"simulated post-click state-wait failure",
],
])(
"forces a verified away-and-back cleanup when %s after the UI returns to its initial state",
async (_failure, harnessOptions, expectedError) => {
const harness = createReactionHarness("neutral", harnessOptions);
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(expectedError);
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(1, 0);
expect(harness.events.slice(-2)).toEqual([
{ action: "dislike", state: "disliked" },
{ action: "dislike", state: "neutral" },
]);
expect(harness.recorder.waitForHandshake.mock.calls).toEqual([
[-1, 1],
[0, 2],
]);
expect(harness.getState()).toBe("neutral");
},
);
test("reports the manual-restore URL when cleanup after a post-dispatch click failure cannot be confirmed", async () => {
const harness = createReactionHarness("neutral", {
clickThrowTransition: 1,
failHandshakes: [1],
rollbackOnClickThrow: true,
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
);
});
test.each([
["first", "neutral", 1, true],
["middle", "liked", 3, false],
["final", "disliked", 6, false],
])(
"forces a verified away-and-back cleanup after a %s transition handshake fails with the UI at its initial state",
async (_position, initialState, failHandshake, rollbackOnHandshakeFailure) => {
const harness = createReactionHarness(initialState, { failHandshake, rollbackOnHandshakeFailure });
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated handshake failure");
let failedState = initialState;
for (const action of EXPECTED_ACTIONS[initialState].slice(0, failHandshake)) {
failedState = nextState(failedState, action);
}
const cleanupAction = initialState === "liked" ? "like" : "dislike";
const awayState = initialState === "neutral" ? "disliked" : "neutral";
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(valueForState(failedState), failHandshake - 1);
expect(harness.events.slice(-2)).toEqual([
{ action: cleanupAction, state: awayState },
{ action: cleanupAction, state: initialState },
]);
expect(harness.recorder.waitForHandshake.mock.calls.slice(-2)).toEqual([
[valueForState(awayState), failHandshake],
[valueForState(initialState), failHandshake + 1],
]);
expect(harness.getState()).toBe(initialState);
},
);
test("reports the manual-restore URL when the verified cleanup round trip fails", async () => {
const harness = createReactionHarness("neutral", {
failHandshakes: [1, 2],
rollbackOnHandshakeFailure: true,
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
);
});
test("reports the manual-restore URL when cleanup cannot match the failed attempt identity", async () => {
const harness = createReactionHarness("neutral", {
failHandshake: 1,
failedVoteUserId: "failed-attempt-user-id",
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(/cleanup reaction did not use the failed attempt's RYD identity/);
});
test("restores the initial state when a post-action visual assertion fails", async () => {
const harness = createReactionHarness("neutral");
const captureReactionVisual = jest.fn(async ({ index, state }) => {
if (index === 3) throw new Error("simulated visual failure");
return `watch-${index}-${state}.png`;
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
captureReactionVisual,
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated visual failure");
expect(captureReactionVisual.mock.calls.map(([capture]) => capture)).toEqual([
{ index: 0, state: "neutral" },
{ index: 1, state: "liked" },
{ index: 2, state: "neutral" },
{ index: 3, state: "disliked" },
]);
expect(harness.getState()).toBe("neutral");
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
});
});
describe("live production reaction matrix visual evidence", () => {
test("captures and returns the initial state plus all six transitions for watch and Shorts", async () => {
const states = new Map([
["abcdefghijk", "neutral"],
["shortsabcde", "neutral"],
]);
let currentVideo;
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureReactionStateVisual: jest.fn(async (capture) => {
expect(states.get(capture.videoId)).toBe(capture.expectedState);
return { screenshotPath: capture.screenshotPath };
}),
clickAction: jest.fn(async (videoId, action) => {
states.set(videoId, nextState(states.get(videoId), action));
}),
openShort: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
openWatch: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
readReactionState: jest.fn(async () => states.get(currentVideo)),
waitForDislikeText: jest.fn(async () => "123"),
waitForReactionState: jest.fn(async (expected) => expect(states.get(currentVideo)).toBe(expected)),
};
const recorders = [];
const createRecorder = jest.fn((videoId) => {
const recorder = {
mark: jest.fn(() => 0),
stop: jest.fn(),
waitForHandshake: jest.fn(async (value) => {
expect(value).toBe(valueForState(states.get(videoId)));
return "shared-user-id";
}),
};
recorders.push(recorder);
return recorder;
});
const consumeVoteApproval = jest.fn();
const makeDirectory = jest.fn();
const options = {
...OPTIONS,
short: "shortsabcde",
watchB: "abcdefghijk",
};
const outputDirectory = "reaction-evidence";
const result = await runProductionReactionMatrixScenario(driver, options, createRecorder, consumeVoteApproval, {
makeDirectory,
outputDirectory,
});
const stateSequence = ["neutral", "liked", "neutral", "disliked", "liked", "disliked", "neutral"];
const expectedWatch = stateSequence.map((state, index) =>
path.join(outputDirectory, `watch-${index}-${state}.png`),
);
const expectedShort = stateSequence.map((state, index) =>
path.join(outputDirectory, `short-${index}-${state}.png`),
);
expect(makeDirectory).toHaveBeenCalledWith(outputDirectory);
expect(consumeVoteApproval).toHaveBeenCalledTimes(1);
expect(driver.captureReactionStateVisual).toHaveBeenCalledTimes(14);
expect(driver.captureReactionStateVisual.mock.calls.slice(0, 7).map(([capture]) => capture)).toEqual(
expectedWatch.map((screenshotPath, index) => ({
expectedState: stateSequence[index],
isShort: false,
runtime: "userscript",
screenshotPath,
videoId: options.watchB,
})),
);
expect(driver.captureReactionStateVisual.mock.calls.slice(7).map(([capture]) => capture)).toEqual(
expectedShort.map((screenshotPath, index) => ({
expectedState: stateSequence[index],
isShort: true,
runtime: "userscript",
screenshotPath,
videoId: options.short,
})),
);
expect(result).toEqual({
evidencePaths: [...expectedWatch, ...expectedShort],
outputDirectory,
short: { evidencePaths: expectedShort, initialState: "neutral", userId: "shared-user-id" },
watch: { evidencePaths: expectedWatch, initialState: "neutral", userId: "shared-user-id" },
});
expect(recorders).toHaveLength(2);
expect(recorders.every((recorder) => recorder.stop.mock.calls.length === 1)).toBe(true);
});
});
describe("live responsive visual scenario", () => {
test("captures watch and userscript Shorts evidence at all responsive widths without actions", async () => {
const originalViewport = { height: 900, width: 1440 };
let currentViewport = originalViewport;
const driver = {
assertCurrentShortsControl: jest.fn(),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
openShort: jest.fn(),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => currentViewport),
setViewportSize: jest.fn(async (viewport) => {
currentViewport = viewport;
}),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const makeDirectory = jest.fn();
const options = { ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" };
const result = await runResponsiveVisualScenario(driver, options, {
makeDirectory,
outputDirectory: "responsive-evidence",
});
expect(makeDirectory).toHaveBeenCalledWith("responsive-evidence");
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
expect(driver.openShort).toHaveBeenCalledWith(options.short);
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureWatchRatioVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureWatchRatioVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-watch-ratio-${width}.png`)),
);
expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-shorts-control-${width}.png`)),
);
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
expect(driver.waitForDislikeText).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length * 2);
expect(result.watch).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shortsSkipped).toBeNull();
});
test("captures watch and native extension Shorts evidence at all responsive widths without actions", async () => {
const originalViewport = { height: 900, width: 1440 };
let currentViewport = originalViewport;
const driver = {
assertCurrentShortsControl: jest.fn(),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureNativeShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
openShort: jest.fn(),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => currentViewport),
setViewportSize: jest.fn(async (viewport) => {
currentViewport = viewport;
}),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const result = await runResponsiveVisualScenario(
driver,
{ ...OPTIONS, runtime: "extension", short: "shortsabcde", watchA: "abcdefghijk" },
{ makeDirectory: jest.fn(), outputDirectory: "responsive-evidence" },
);
expect(driver.openShort).toHaveBeenCalledWith("shortsabcde");
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureNativeShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureNativeShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`extension-shorts-control-${width}.png`)),
);
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shortsSkipped).toBeNull();
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
});
test("restores the original viewport when a visual assertion fails", async () => {
const originalViewport = { height: 800, width: 1200 };
const driver = {
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureWatchRatioVisual: jest.fn(async () => Promise.reject(new Error("visual failed"))),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => originalViewport),
setViewportSize: jest.fn(),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
await expect(
runResponsiveVisualScenario(
driver,
{ ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" },
{
makeDirectory: jest.fn(),
outputDirectory: "responsive-evidence",
},
),
).rejects.toThrow("visual failed");
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
});
});

Some files were not shown because too many files have changed in this diff Show More