Merge pull request #1203 from andrebk/watch-providers

Show watch providers on media details page
This commit is contained in:
Xila Cai
2026-02-17 23:11:48 +01:00
committed by GitHub
10 changed files with 218 additions and 15 deletions

View File

@@ -149,9 +149,10 @@ def movie(media_id):
if data is None:
url = f"{base_url}/movie/{media_id}"
appends = ["recommendations", "external_ids", "credits", "watch/providers"]
params = {
**base_params,
"append_to_response": "recommendations,external_ids,credits",
"append_to_response": ",".join(appends),
}
try:
@@ -229,6 +230,7 @@ def movie(media_id):
),
},
"external_links": get_external_links(response.get("external_ids", {})),
"providers": response.get("watch/providers", {}).get("results", {}),
}
cache.set(cache_key, data)
@@ -272,14 +274,19 @@ def enrich_season_with_tv_data(season_data, tv_data, media_id, season_number):
def fetch_and_cache_seasons(media_id, season_numbers, tv_data):
"""Fetch uncached seasons from API and cache them."""
url = f"{base_url}/tv/{media_id}"
base_append = "recommendations,external_ids"
max_seasons_per_request = 18
base_append = "recommendations,external_ids,watch/providers"
max_seasons_per_request = 8
fetched_tv_data = tv_data
result_data = {}
for i in range(0, len(season_numbers), max_seasons_per_request):
season_subset = season_numbers[i : i + max_seasons_per_request]
append_text = ",".join([f"season/{season}" for season in season_subset])
append_text = ",".join(
[
f"season/{season},season/{season}/watch/providers"
for season in season_subset
]
)
params = {
**base_params,
@@ -315,7 +322,9 @@ def fetch_and_cache_seasons(media_id, season_numbers, tv_data):
not_found_error = type("Error", (), {"response": not_found_response})
raise services.ProviderAPIError(msg, error=not_found_error, details=msg)
season_data = process_season(response[season_key])
season_data = process_season(
response[season_key], response[f"{season_key}/watch/providers"]
)
season_data = enrich_season_with_tv_data(
season_data,
fetched_tv_data,
@@ -369,7 +378,7 @@ def tv(media_id):
url = f"{base_url}/tv/{media_id}"
params = {
**base_params,
"append_to_response": "recommendations,external_ids",
"append_to_response": "recommendations,external_ids,watch/providers",
}
try:
@@ -432,10 +441,11 @@ def process_tv(response):
"external_links": get_external_links(response.get("external_ids", {})),
"last_episode_season": last_episode["season_number"] if last_episode else None,
"next_episode_season": next_episode["season_number"] if next_episode else None,
"providers": response.get("watch/providers", {}).get("results", {}),
}
def process_season(response):
def process_season(response, providers_response):
"""Process the metadata for the selected season from The Movie Database."""
episodes = response["episodes"]
num_episodes = len(episodes)
@@ -473,6 +483,7 @@ def process_season(response):
"total_runtime": total_runtime,
},
"episodes": response["episodes"],
"providers": providers_response.get("results", {}),
}
@@ -641,6 +652,31 @@ def get_collection(collection_response):
]
def filter_providers(all_providers, region):
"""Filter watch providers by region."""
if region == "":
return None
if not all_providers:
return []
# Create a dict to get rid of duplicates across different provider types
region_providers = all_providers.get(region, {})
flatrate_providers = region_providers.get("flatrate", [])
free_providers = region_providers.get("free", [])
providers = {}
for provider in [*flatrate_providers, *free_providers]:
providers[provider.get("provider_id")] = provider
# Convert dict back to list and add image URLs
providers = list(providers.values())
for provider in providers:
provider["image"] = get_image_url(provider.get("logo_path"))
providers.sort(key=lambda e: e.get("display_priority", 999))
return providers
def process_episodes(season_metadata, episodes_in_db):
"""Process the episodes for the selected season."""
episodes_metadata = []
@@ -722,3 +758,37 @@ def episode(media_id, season_number, episode_number):
error=not_found_error,
details=msg,
)
def watch_provider_regions():
"""Return the available watch provider regions from The Movie Database."""
cache_key = f"{Sources.TMDB.value}_watch_provider_regions"
data = cache.get(cache_key)
if data is None:
url = f"{base_url}/watch/providers/regions"
params = {**base_params}
try:
response = services.api_request(
Sources.TMDB.value,
"GET",
url,
params=params,
)
except requests.exceptions.HTTPError as error:
handle_error(error)
data = [("", "Disabled")]
regions = response.get("results", [])
for region in sorted(regions, key=lambda r: r.get("english_name", "")):
key = region.get("iso_3166_1")
name = region.get("english_name")
if key:
if not name:
name = key
data.append((key, name))
cache.set(cache_key, data)
return data

View File

@@ -215,11 +215,20 @@ def media_details(request, source, media_type, media_id, title): # noqa: ARG001
)
)
if media_type in ["tv", "movie"]:
watch_providers = tmdb.filter_providers(
media_metadata.get("providers"), request.user.watch_provider_region
)
else:
watch_providers = None
context = {
"media": media_metadata,
"media_type": media_type,
"user_medias": user_medias,
"current_instance": current_instance,
"watch_providers": watch_providers,
"watch_provider_region": request.user.watch_provider_region,
}
return render(request, "app/media_details.html", context)
@@ -275,6 +284,10 @@ def season_details(request, source, media_id, title, season_number): # noqa: AR
"media_type": MediaTypes.SEASON.value,
"user_medias": user_medias,
"current_instance": current_instance,
"watch_providers": tmdb.filter_providers(
season_metadata.get("providers"), request.user.watch_provider_region
),
"watch_provider_region": request.user.watch_provider_region,
}
return render(request, "app/media_details.html", context)

View File

@@ -3,10 +3,10 @@
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
@@ -109,7 +109,7 @@
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
@@ -136,7 +136,7 @@
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
@@ -233,13 +233,13 @@
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@@ -429,6 +429,9 @@
.my-3 {
margin-block: calc(var(--spacing) * 3);
}
.my-4 {
margin-block: calc(var(--spacing) * 4);
}
.my-5 {
margin-block: calc(var(--spacing) * 5);
}
@@ -582,6 +585,10 @@
.aspect-2\/3 {
aspect-ratio: 2/3;
}
.size-10 {
width: calc(var(--spacing) * 10);
height: calc(var(--spacing) * 10);
}
.h-3 {
height: calc(var(--spacing) * 3);
}
@@ -906,6 +913,9 @@
.flex-wrap {
flex-wrap: wrap;
}
.place-content-evenly {
place-content: space-evenly;
}
.items-baseline {
align-items: baseline;
}

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="{{ classes }}">
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/>
<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/>
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/>
<circle cx="12" cy="12" r="10"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@@ -435,6 +435,30 @@
{# Media Details #}
<h2 class="text-xl font-bold mb-4">Details</h2>
{% if watch_provider_region == "UNSET" or watch_providers is not None %}
<div class="bg-[#2a2f35] p-4 rounded-lg my-4">
<h3 class="text-sm font-semibold text-gray-400 mb-2">STREAMING</h3>
<div class="flex flex-wrap items-center gap-2 place-content-evenly">
{% if watch_provider_region == "UNSET" %}
{% url 'preferences' as preferences_url %}
<p class="text-sm">
Watch provider region is not configured. Set a region or disable this feature in the
<a href="{{ preferences_url }}"
class="hover:text-indigo-500 hover:underline">preferences</a>.
</p>
{% elif watch_providers %}
{% for provider in watch_providers %}
<img class="size-10 rounded-md"
src="{{ provider.image }}"
title="{{ provider.provider_name }}"
alt="{{ provider.provider_name }}" />
{% endfor %}
{% else %}
<p>No watch providers</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="bg-[#2a2f35] p-4 rounded-lg text-center md:text-start">
{% if media.details.items %}
{% for key, value in media.details.items %}

View File

@@ -72,6 +72,23 @@
</p>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2 text-lg font-medium text-gray-200">
{% include "app/icons/page.svg" with classes="w-5 h-5 text-indigo-400" %}
<h3>Attribution</h3>
</div>
<p class="text-gray-300 pl-7">
Movie and TV streaming providers from
<a href="https://www.justwatch.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-indigo-400 hover:text-indigo-300 transition-colors">
JustWatch
{% include "app/icons/external-link.svg" with classes="w-3 h-3 ml-1" %}
</a>
</p>
</div>
<div class="mt-8 pt-4 border-t border-gray-700">
<p class="text-gray-400 text-sm">
Version: <span class="font-mono">{{ version }}</span>

View File

@@ -169,6 +169,28 @@
</div>
</div>
{# Watch Provider Region #}
<div class="mb-5">
<div class="flex items-center justify-between p-3 bg-[#39404b] rounded-md">
<div class="flex-1">
<div class="flex items-center text-gray-200 mb-1">
{% include "app/icons/globe.svg" with classes="w-5 h-5 mr-2" %}
<span class="text-sm font-medium">Watch provider region</span>
</div>
<p class="text-xs text-gray-400 ml-7">
Choose the region to show watch providers for. Setting this to disabled will hide the box from the media page.
</p>
</div>
<select name="watch_provider_region"
class="ml-4 p-2 bg-[#39404b] rounded-md text-white text-sm border border-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-400">
{% for choice in watch_provider_choices %}
<option value="{{ choice.0 }}"
{% if user.watch_provider_region == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="border-t border-gray-600 my-5"></div>
{# Media Types Settings #}

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-14 10:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0049_add_hide_zero_rating'),
]
operations = [
migrations.AddField(
model_name='user',
name='watch_provider_region',
field=models.CharField(default='UNSET', help_text='Region to show watch providers for', max_length=5),
),
]

View File

@@ -330,6 +330,13 @@ class User(AbstractUser):
help_text="Hide zero ratings from media cards",
)
# Watch provider region
watch_provider_region = models.CharField(
max_length=5,
default="UNSET",
help_text="Region to show watch providers for",
)
# Calendar preferences
calendar_layout = models.CharField(
max_length=20,

View File

@@ -13,6 +13,7 @@ from django.views.decorators.http import require_GET, require_http_methods, requ
from django_celery_beat.models import PeriodicTask
from app.models import Item, MediaTypes
from app.providers import tmdb
from users.forms import NotificationSettingsForm, PasswordChangeForm, UserUpdateForm
from users.models import DateFormatChoices, QuickWatchDateChoices, TimeFormatChoices
@@ -213,6 +214,7 @@ def preferences(request):
"""Render the preferences settings page."""
media_types = MediaTypes.values
media_types.remove(MediaTypes.EPISODE.value)
watch_provider_regions = tmdb.watch_provider_regions()
if request.method == "GET":
return render(
@@ -223,6 +225,7 @@ def preferences(request):
"quick_watch_date_choices": QuickWatchDateChoices.choices,
"date_format_choices": DateFormatChoices.choices,
"time_format_choices": TimeFormatChoices.choices,
"watch_provider_choices": watch_provider_regions,
},
)
@@ -252,6 +255,12 @@ def preferences(request):
)
media_types_checked = request.POST.getlist("media_types_checkboxes")
provider_region = request.POST.get("watch_provider_region", "")
if provider_region in [region[0] for region in watch_provider_regions]:
request.user.watch_provider_region = provider_region
else:
request.user.watch_provider_region = "UNSET"
# Update user preferences for each media type
for media_type in media_types:
setattr(