Ability to set a country, both in server and user settings, to have the correct streaming provider (but doesn't affect language) (#463)

* Country support for watch providers (#1, not functional)

* frontend dropdowns now actually read and write the correct setting

* user country setting now respects the server side default when creating a new user

* watch providers correctly show based on country, but changing the coutnry requires restarting the container to take effect

* flushing cache when updating user settings

* non functional, initial support for getting all the countries of which tmdb has streaming providers for to let users pick one

* users get shown a list of country codes to pick from and the selection gets saved and used, but the dropdown menu doesn't allow for scrolling

* limited dropdown height and auto scroll to the letter the user hits

* simplified auto scroll code

* the dropdown to choose countries now shows their full english name

* server/user region improvements

* DropDown: remove console log

used to test

* DropDown: Add keypress event listener on main div

instead of document body, so only triggered when key pressed whilst focused on dropdown.

---------

Co-authored-by: Stig <stig.narnia@gmail.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
This commit is contained in:
stignarnia
2024-04-14 04:57:51 +02:00
committed by GitHub
parent 4a7503bbfd
commit c5d817c68c
14 changed files with 222 additions and 22 deletions
+6
View File
@@ -89,6 +89,8 @@ type UserSettings struct {
// even if the watched item state has since been changed.
// Also if user wants to show in watched stats.
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
// User's country to get correct content streaming providers.
Country *string `gorm:"default:'US'" json:"country"`
}
// Holds third party service auth tokens for users.
@@ -291,6 +293,8 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
user.Permissions = initialPerm
}
user.Country = &Config.DEFAULT_COUNTRY
res := db.Create(&user)
if res.Error != nil {
// If error is because unique contraint failed.. user already exists
@@ -422,6 +426,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
dbUser.ThirdPartyAuth = resp.AccessToken
dbUser.Username = resp.User.Name
dbUser.Type = JELLYFIN_USER
dbUser.Country = &Config.DEFAULT_COUNTRY
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
@@ -484,6 +489,7 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
AuthToken: lr.AuthToken,
AuthToken2: homeAuthToken,
})
dbUser.Country = &Config.DEFAULT_COUNTRY
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
+9 -1
View File
@@ -26,6 +26,10 @@ type ServerConfig struct {
// it strong, just like a very long, complicated password.
JWT_SECRET string `json:",omitempty"`
// Default country for new users. This is used to set the default
// region to get correct content streaming providers.
DEFAULT_COUNTRY string `json:",omitempty"`
// Optional: Point to your Jellyfin install
// to enable it as an auth provider.
JELLYFIN_HOST string `json:",omitempty"`
@@ -67,6 +71,7 @@ type ServerConfig struct {
func (c *ServerConfig) GetSafe() ServerConfig {
return ServerConfig{
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
DEFAULT_COUNTRY: c.DEFAULT_COUNTRY,
JELLYFIN_HOST: c.JELLYFIN_HOST,
TMDB_KEY: c.TMDB_KEY,
PLEX_HOST: c.PLEX_HOST,
@@ -126,7 +131,8 @@ func generateConfig() error {
cfg := ServerConfig{
JWT_SECRET: key,
// Other defaults..
SIGNUP_ENABLED: true,
DEFAULT_COUNTRY: "US",
SIGNUP_ENABLED: true,
}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
@@ -151,6 +157,8 @@ func updateConfig(k string, v any) error {
} else if k == "DEBUG" {
Config.DEBUG = v.(bool)
setLoggingLevel()
} else if k == "DEFAULT_COUNTRY" {
Config.DEFAULT_COUNTRY = v.(string)
} else {
return errors.New("invalid setting")
}
+10
View File
@@ -377,3 +377,13 @@ func upcomingTv() (TMDBUpcomingShows, error) {
}
return *resp, nil
}
func regions() (TMDBRegions, error) {
resp := new(TMDBRegions)
err := tmdbRequest("/watch/providers/regions", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete regions request!", "error", err.Error())
return TMDBRegions{}, errors.New("failed to complete regions request")
}
return *resp, nil
}
+17 -6
View File
@@ -8,13 +8,24 @@ import (
// Location middleware
func WhereaboutsRequired() gin.HandlerFunc {
// TODO: This should also take into account a user
// preference (of location.. which also needs to be
// added) and server preference (env var). The US is
// good enough for now as a default.
return func(c *gin.Context) {
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
c.Set("userCountry", "US")
region := c.Query("region")
slog.Debug("WhereaboutsRequired: middleware hit", "region", region)
if region == "" {
// If no region is passed, default to server region.
if Config.DEFAULT_COUNTRY != "" {
slog.Debug("WhereaboutsRequired: Using server default country.", "default_country", Config.DEFAULT_COUNTRY)
c.Set("userCountry", Config.DEFAULT_COUNTRY)
c.Next()
return
}
// If no server region set, default to US.
slog.Debug("WhereaboutsRequired: Using hard coded default (US).")
c.Set("userCountry", "US")
c.Next()
return
}
c.Set("userCountry", region)
c.Next()
}
}
+13 -7
View File
@@ -94,7 +94,7 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get movie details (for movie page)
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/movie/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -122,7 +122,7 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get tv details (for tv page)
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -240,6 +240,16 @@ func (b *BaseRouter) addContentRoutes() {
}
c.JSON(http.StatusOK, content)
}))
// Available regions for watch providers
content.GET("/regions", func(c *gin.Context) {
r, err := regions()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, r)
})
}
func (b *BaseRouter) addGameRoutes() {
@@ -388,12 +398,8 @@ func (b *BaseRouter) addWatchedRoutes() {
watched.DELETE(":id", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Status(400)
return
}
userId := c.MustGet("userId").(uint)
if err == nil {
userId := c.MustGet("userId").(uint)
response, err := removeWatched(b.db, userId, uint(id))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
+8
View File
@@ -491,6 +491,14 @@ type TMDBKeywords struct {
} `json:"results"`
}
type TMDBRegions struct {
Results []struct {
ISO3166_1 string `json:"iso_3166_1"`
English_Name string `json:"english_name"`
Native_Name string `json:"native_name"`
} `json:"results"`
}
func getTMDBKey() string {
if Config.TMDB_KEY != "" {
return Config.TMDB_KEY
+5
View File
@@ -58,12 +58,16 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
if ur.IncludePreviouslyWatched != nil {
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
}
if ur.Country != nil {
user.Country = ur.Country
}
db.Save(&user)
return UserSettings{
Private: user.Private,
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
Country: user.Country,
}, nil
}
@@ -80,6 +84,7 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
Country: user.Country,
}, nil
}
+47 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import type { DropDownItem } from "@/types";
import Icon from "./Icon.svelte";
import { onMount } from "svelte";
export let options: string[] | DropDownItem[];
// If we are using DropDownItems[] as options.
export let isDropDownItem = false;
export let active: string | number | undefined = undefined;
export let placeholder: string;
export let blendIn: boolean = false;
@@ -11,15 +14,54 @@
let activeValue: string;
let open = false;
let ulElement: HTMLUListElement;
let mainElement: HTMLDivElement;
function handleKeyPress(event: KeyboardEvent) {
if (!open || disabled) return; // Don't handle if closed or disabled
const pressedLetter = event.key.toLowerCase();
// Filter options that start with the pressed letter (ignoring case)
const filteredOptions = options.filter((o) =>
typeof o === "string"
? o.toLowerCase().startsWith(pressedLetter)
: o.value.toLowerCase().startsWith(pressedLetter)
);
// If there are filtered options, select the first one
let f: string | number | undefined;
if (filteredOptions.length > 0) {
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
}
// Find first button with text content starting with letter pressed and scroll it into view.
const btns = ulElement?.querySelectorAll("button");
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
btn.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
}
$: {
if (typeof active === "string") {
if (typeof active === "string" && !isDropDownItem) {
activeValue = active;
} else {
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
if (v && typeof v !== "string") activeValue = v.value;
}
}
onMount(() => {
mainElement.addEventListener("keypress", handleKeyPress);
return () => {
mainElement.removeEventListener("keypress", handleKeyPress);
};
});
</script>
<div
@@ -28,12 +70,13 @@
typeof active === "undefined" ? "placeholder-shown" : "",
blendIn ? "blend-in" : ""
].join(" ")}
bind:this={mainElement}
>
<button on:click={() => (open = !open)} {disabled}>
{activeValue ? activeValue : placeholder}
<Icon i="chevron" facing={open ? "up" : "down"} />
</button>
<ul>
<ul bind:this={ulElement}>
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
<li>
<button
@@ -111,6 +154,8 @@
border-bottom-right-radius: 5px;
background-color: $bg-color;
z-index: 99;
max-height: 20vh;
overflow-y: auto;
li {
width: 100%;
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import axios from "axios";
import DropDown from "./DropDown.svelte";
import type { DropDownItem, TMDBRegions } from "@/types";
import Error from "./Error.svelte";
export let selectedCountry: string = "US";
export let disabled = false;
export let onChange: (country: string) => void;
let mappedCountries: DropDownItem[];
async function getCountries() {
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
mappedCountries = c.results
.map((cc) => {
return {
id: cc.iso_3166_1,
value: cc.english_name
} as DropDownItem;
})
.sort((a, b) => a.value.localeCompare(b.value));
}
</script>
{#await getCountries() then}
<DropDown
placeholder="Select a country"
bind:active={selectedCountry}
options={mappedCountries}
onChange={() => onChange(selectedCountry)}
isDropDownItem={true}
{disabled}
/>
{:catch err}
<Error error={err} pretty="Failed to load countries!" />
{/await}
+6 -2
View File
@@ -6,7 +6,7 @@
import Status from "@/lib/Status.svelte";
import HorizontalList from "@/lib/HorizontalList.svelte";
import { contentExistsOnJellyfin, updateWatched } from "@/lib/util/api";
import { serverFeatures, watchedList } from "@/store";
import { serverFeatures, userSettings, watchedList } from "@/store";
import type {
ArrDetailsResponse,
TMDBContentCredits,
@@ -29,6 +29,8 @@
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
import ArrRequestButton from "@/lib/request/ArrRequestButton.svelte";
$: settings = $userSettings;
export let data;
let trailer: string | undefined;
@@ -66,7 +68,9 @@
if (!movieId) {
return;
}
const data = (await axios.get(`/content/movie/${movieId}`)).data as TMDBMovieDetails;
const data = (
await axios.get(`/content/movie/${movieId}`, { params: { region: settings?.country } })
).data as TMDBMovieDetails;
if (data.videos?.results?.length > 0) {
const t = data.videos.results.find((v) => v.type?.toLowerCase() === "trailer");
if (t?.key) {
+19
View File
@@ -15,6 +15,7 @@
import UserAvatar from "@/lib/img/UserAvatar.svelte";
import PwChangeModal from "@/routes/(app)/profile/modals/PwChangeModal.svelte";
import SyncModal from "./modals/SyncModal.svelte";
import RegionDropDown from "@/lib/RegionDropDown.svelte";
$: user = $userInfo;
$: settings = $userSettings;
@@ -24,6 +25,7 @@
let privateThoughtsDisabled = false;
let exportDisabled = false;
let hideSpoilersDisabled = false;
let countryDisabled = false;
let includePreviouslyWatchedDisabled = false;
let pwChangeModalOpen = false;
let getProfilePromise = getProfile();
@@ -194,6 +196,22 @@
</div>
</div>
<Setting
title="Country"
desc="What country would you like to see available streaming providers for?"
>
<RegionDropDown
selectedCountry={settings?.country}
disabled={countryDisabled}
onChange={(c) => {
countryDisabled = true;
updateUserSetting("country", c, () => {
countryDisabled = false;
});
}}
/>
</Setting>
<Setting title="Private" desc="Hide your profile from others?" row>
<Checkbox
name="private"
@@ -261,6 +279,7 @@
}}
/>
</Setting>
<div class="row btns">
<button on:click={() => goto("/import")}>Import</button>
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</button>
+28 -1
View File
@@ -1,9 +1,16 @@
<script lang="ts">
import Checkbox from "@/lib/Checkbox.svelte";
import DropDown from "@/lib/DropDown.svelte";
import PageError from "@/lib/PageError.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import type { Content, RadarrSettings, ServerConfig, SonarrSettings } from "@/types";
import type {
Content,
RadarrSettings,
ServerConfig,
SonarrSettings,
DropDownItem
} from "@/types";
import axios from "axios";
import SonarrModal from "./modals/SonarrModal.svelte";
import SettingsList from "@/lib/settings/SettingsList.svelte";
@@ -15,6 +22,7 @@
import Error from "@/lib/Error.svelte";
import Stat from "@/lib/stats/Stat.svelte";
import TwitchModal from "./modals/TwitchModal.svelte";
import RegionDropDown from "@/lib/RegionDropDown.svelte";
let serverConfig: ServerConfig;
let sonarrModalOpen = false;
@@ -30,6 +38,10 @@
let jfDisabled = false;
let tmdbkDisabled = false;
let plexHostDisabled = false;
let countryDisabled = false;
let selectedCountry: string;
let countries: any;
let countriesDropdown: DropDownItem[] = [];
async function getServerConfig() {
serverConfig = (await axios.get(`/server/config`)).data as ServerConfig;
@@ -120,6 +132,21 @@
<Spinner />
{:then}
<h3>General</h3>
<Setting
title="Default Country"
desc="Default country for new users. This can be changed per user and won't affect existing users."
>
<RegionDropDown
selectedCountry={serverConfig.DEFAULT_COUNTRY}
disabled={countryDisabled}
onChange={(c) => {
countryDisabled = true;
updateServerConfig("DEFAULT_COUNTRY", c, () => {
countryDisabled = false;
});
}}
/>
</Setting>
<Setting
title="Jellyfin Host"
desc="Point to your Jellyfin server to enable related features. Don't change server after
+6 -2
View File
@@ -16,7 +16,7 @@
import VideoEmbedModal from "@/lib/content/VideoEmbedModal.svelte";
import { contentExistsOnJellyfin, updateWatched } from "@/lib/util/api";
import { getTopCrew } from "@/lib/util/helpers.js";
import { serverFeatures, watchedList } from "@/store";
import { serverFeatures, userSettings, watchedList } from "@/store";
import type {
TMDBContentCredits,
TMDBContentCreditsCrew,
@@ -29,6 +29,8 @@
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
import ArrRequestButton from "@/lib/request/ArrRequestButton.svelte";
$: settings = $userSettings;
export let data;
let trailer: string | undefined;
@@ -64,7 +66,9 @@
if (!showId) {
return;
}
const data = (await axios.get(`/content/tv/${showId}`)).data as TMDBShowDetails;
const data = (
await axios.get(`/content/tv/${showId}`, { params: { region: settings?.country } })
).data as TMDBShowDetails;
if (data.videos?.results?.length > 0) {
const t = data.videos.results.find((v) => v.type?.toLowerCase() === "trailer");
if (t?.key) {
+11 -1
View File
@@ -151,6 +151,7 @@ export interface UserSettings {
privateThoughts: boolean;
hideSpoilers: boolean;
includePreviouslyWatched: boolean;
country: string;
}
export interface ChangePasswordForm {
@@ -659,6 +660,14 @@ export interface TMDBKeywords {
}[];
}
export interface TMDBRegions {
results: {
iso_3166_1: string;
english_name: string;
native_name: string;
}[];
}
export interface ContentSearch {
page: number;
results: (ContentSearchMovie | ContentSearchTv | ContentSearchPerson)[];
@@ -769,6 +778,7 @@ export interface ManagedUser {
}
export interface ServerConfig {
DEFAULT_COUNTRY: string;
JELLYFIN_HOST: string;
SIGNUP_ENABLED: boolean;
TMDB_KEY: string;
@@ -805,7 +815,7 @@ export interface TwitchSettings {
}
export interface DropDownItem {
id: number;
id: number | string;
value: string;
}