diff --git a/_scripts/getRegions.mjs b/_scripts/getRegions.mjs
new file mode 100644
index 000000000000..90adce4d0dfd
--- /dev/null
+++ b/_scripts/getRegions.mjs
@@ -0,0 +1,148 @@
+/**
+ * This script updates the files in static/geolocations with the available locations on YouTube.
+ *
+ * It tries to map every active FreeTube language (static/locales/activelocales.json)
+ * to it's equivalent on YouTube.
+ *
+ * It then uses those language mappings,
+ * to scrape the location selection menu on the YouTube website, in every mapped language.
+ *
+ * All languages it couldn't find on YouTube, that don't have manually added mapping,
+ * get logged to the console, as well as all unmapped YouTube languages.
+ */
+
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
+import { dirname } from 'path'
+import { fileURLToPath } from 'url'
+import { Innertube, Misc } from 'youtubei.js'
+
+const STATIC_DIRECTORY = `${dirname(fileURLToPath(import.meta.url))}/../static`
+
+const activeLanguagesPath = `${STATIC_DIRECTORY}/locales/activeLocales.json`
+/** @type {string[]} */
+const activeLanguages = JSON.parse(readFileSync(activeLanguagesPath, { encoding: 'utf8' }))
+
+// en-US is en on YouTube
+const initialResponse = await scrapeLanguage('en')
+
+// Scrape language menu in en-US
+
+/** @type {string[]} */
+const youTubeLanguages = initialResponse.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items
+ .map(({ compactLinkRenderer }) => {
+ return compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectLanguageCommand.hl
+ })
+
+// map FreeTube languages to their YouTube equivalents
+
+const foundLanguageNames = ['en-US']
+const unusedYouTubeLanguageNames = []
+const languagesToScrape = []
+
+for (const language of youTubeLanguages) {
+ if (activeLanguages.includes(language)) {
+ foundLanguageNames.push(language)
+ languagesToScrape.push({
+ youTube: language,
+ freeTube: language
+ })
+ } else if (activeLanguages.includes(language.replace('-', '_'))) {
+ const withUnderScore = language.replace('-', '_')
+ foundLanguageNames.push(withUnderScore)
+ languagesToScrape.push({
+ youTube: language,
+ freeTube: withUnderScore
+ })
+ }
+ // special cases
+ else if (language === 'de') {
+ foundLanguageNames.push('de-DE')
+ languagesToScrape.push({
+ youTube: 'de',
+ freeTube: 'de-DE'
+ })
+ } else if (language === 'fr') {
+ foundLanguageNames.push('fr-FR')
+ languagesToScrape.push({
+ youTube: 'fr',
+ freeTube: 'fr-FR'
+ })
+ } else if (language === 'no') {
+ // according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
+ // "no" is the macro language for "nb" and "nn"
+ foundLanguageNames.push('nb_NO', 'nn')
+ languagesToScrape.push({
+ youTube: 'no',
+ freeTube: 'nb_NO'
+ })
+ languagesToScrape.push({
+ youTube: 'no',
+ freeTube: 'nn'
+ })
+ } else if (language !== 'en') {
+ unusedYouTubeLanguageNames.push(language)
+ }
+}
+
+console.log("Active FreeTube languages that aren't available on YouTube:")
+console.log(activeLanguages.filter(lang => !foundLanguageNames.includes(lang)).sort())
+
+console.log("YouTube languages that don't have an equivalent active FreeTube language:")
+console.log(unusedYouTubeLanguageNames.sort())
+
+// Scrape the location menu in various languages and write files to the file system
+
+rmSync(`${STATIC_DIRECTORY}/geolocations`, { recursive: true })
+mkdirSync(`${STATIC_DIRECTORY}/geolocations`)
+
+processGeolocations('en-US', 'en', initialResponse)
+
+for (const { youTube, freeTube } of languagesToScrape) {
+ const response = await scrapeLanguage(youTube)
+
+ processGeolocations(freeTube, youTube, response)
+}
+
+
+
+async function scrapeLanguage(youTubeLanguageCode) {
+ const session = await Innertube.create({
+ retrieve_player: false,
+ generate_session_locally: true,
+ lang: youTubeLanguageCode
+ })
+
+ return await session.actions.execute('/account/account_menu')
+}
+
+function processGeolocations(freeTubeLanguage, youTubeLanguage, response) {
+ const geolocations = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[1].multiPageMenuSectionRenderer.items[3].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items
+ .map(({ compactLinkRenderer }) => {
+ return {
+ name: new Misc.Text(compactLinkRenderer.title).toString().trim(),
+ code: compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectCountryCommand.gl
+ }
+ })
+
+ const normalisedFreeTubeLanguage = freeTubeLanguage.replace('_', '-')
+
+ // give Intl.Collator 4 locales, in the hopes that it supports one of them
+ // deduplicate the list so it doesn't have to do duplicate work
+ const localeSet = new Set()
+ localeSet.add(normalisedFreeTubeLanguage)
+ localeSet.add(youTubeLanguage)
+ localeSet.add(normalisedFreeTubeLanguage.split('-')[0])
+ localeSet.add(youTubeLanguage.split('-')[0])
+
+ const locales = Array.from(localeSet)
+
+ // only sort if node supports sorting the language, otherwise hope that YouTube's sorting was correct
+ // node 20.3.1 doesn't support sorting `eu`
+ if (Intl.Collator.supportedLocalesOf(locales).length > 0) {
+ const collator = new Intl.Collator(locales)
+
+ geolocations.sort((a, b) => collator.compare(a.name, b.name))
+ }
+
+ writeFileSync(`${STATIC_DIRECTORY}/geolocations/${freeTubeLanguage}.json`, JSON.stringify(geolocations))
+}
diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js
index 896a0d80c4e5..420d350482ea 100644
--- a/_scripts/webpack.web.config.js
+++ b/_scripts/webpack.web.config.js
@@ -170,7 +170,7 @@ config.plugins.push(
processLocalesPlugin,
new webpack.DefinePlugin({
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
- 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')))
+ 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', '')))
}),
new CopyWebpackPlugin({
patterns: [
diff --git a/package.json b/package.json
index a30c927818c3..d0a6cace4251 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"dev:web": "node _scripts/dev-runner.js --web",
"dev-runner": "node _scripts/dev-runner.js",
"get-instances": "node _scripts/getInstances.js",
+ "get-regions": "node _scripts/getRegions.mjs",
"lint-all": "run-p lint lint-json lint-style",
"lint-fix": "eslint --fix --ext .js,.vue ./",
"lint": "eslint --ext .js,.vue ./",
diff --git a/src/renderer/components/ft-select/ft-select.js b/src/renderer/components/ft-select/ft-select.js
index e6050edadff1..83ab2f523ac7 100644
--- a/src/renderer/components/ft-select/ft-select.js
+++ b/src/renderer/components/ft-select/ft-select.js
@@ -1,4 +1,4 @@
-import { defineComponent } from 'vue'
+import { defineComponent, nextTick } from 'vue'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { sanitizeForHtmlId } from '../../helpers/accessibility'
@@ -45,5 +45,17 @@ export default defineComponent({
sanitizedPlaceholder: function() {
return sanitizeForHtmlId(this.placeholder)
}
+ },
+ watch: {
+ // update the selected value in the menu when the list of values changes
+
+ // e.g. when you change the display language, the locations list gets updated
+ // as the locations list is sorted alphabetically for the language, the ordering can be different
+ // so we need to ensure that the correct location is selected after a language change
+ selectValues: function () {
+ nextTick(() => {
+ this.$refs.select.value = this.value
+ })
+ }
}
})
diff --git a/src/renderer/components/ft-select/ft-select.vue b/src/renderer/components/ft-select/ft-select.vue
index b935405f2f7a..fb73a3b54323 100644
--- a/src/renderer/components/ft-select/ft-select.vue
+++ b/src/renderer/components/ft-select/ft-select.vue
@@ -2,6 +2,7 @@