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 @@