Show watch providers on media details page

This commit is contained in:
Andreas Bro Kolstø
2026-02-15 13:56:59 +01:00
parent c2d4c551e5
commit ce3613aa9c
10 changed files with 194 additions and 15 deletions

View File

@@ -151,7 +151,7 @@ def movie(media_id):
url = f"{base_url}/movie/{media_id}" url = f"{base_url}/movie/{media_id}"
params = { params = {
**base_params, **base_params,
"append_to_response": "recommendations,external_ids,credits", "append_to_response": "recommendations,external_ids,credits,watch/providers",
} }
try: try:
@@ -231,6 +231,7 @@ def movie(media_id):
), ),
}, },
"external_links": get_external_links(response.get("external_ids", {})), "external_links": get_external_links(response.get("external_ids", {})),
"providers": response.get("watch/providers", {}).get("results", {}),
} }
cache.set(cache_key, data) cache.set(cache_key, data)
@@ -274,14 +275,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): def fetch_and_cache_seasons(media_id, season_numbers, tv_data):
"""Fetch uncached seasons from API and cache them.""" """Fetch uncached seasons from API and cache them."""
url = f"{base_url}/tv/{media_id}" url = f"{base_url}/tv/{media_id}"
base_append = "recommendations,external_ids" base_append = "recommendations,external_ids,watch/providers"
max_seasons_per_request = 18 max_seasons_per_request = 8
fetched_tv_data = tv_data fetched_tv_data = tv_data
result_data = {} result_data = {}
for i in range(0, len(season_numbers), max_seasons_per_request): for i in range(0, len(season_numbers), max_seasons_per_request):
season_subset = season_numbers[i : i + 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 = { params = {
**base_params, **base_params,
@@ -317,7 +323,9 @@ def fetch_and_cache_seasons(media_id, season_numbers, tv_data):
not_found_error = type("Error", (), {"response": not_found_response}) not_found_error = type("Error", (), {"response": not_found_response})
raise services.ProviderAPIError(msg, error=not_found_error, details=msg) 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 = enrich_season_with_tv_data(
season_data, season_data,
fetched_tv_data, fetched_tv_data,
@@ -371,7 +379,7 @@ def tv(media_id):
url = f"{base_url}/tv/{media_id}" url = f"{base_url}/tv/{media_id}"
params = { params = {
**base_params, **base_params,
"append_to_response": "recommendations,external_ids", "append_to_response": "recommendations,external_ids,watch/providers",
} }
try: try:
@@ -434,10 +442,11 @@ def process_tv(response):
"external_links": get_external_links(response.get("external_ids", {})), "external_links": get_external_links(response.get("external_ids", {})),
"last_episode_season": last_episode["season_number"] if last_episode else None, "last_episode_season": last_episode["season_number"] if last_episode else None,
"next_episode_season": next_episode["season_number"] if next_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=None):
"""Process the metadata for the selected season from The Movie Database.""" """Process the metadata for the selected season from The Movie Database."""
episodes = response["episodes"] episodes = response["episodes"]
num_episodes = len(episodes) num_episodes = len(episodes)
@@ -475,6 +484,7 @@ def process_season(response):
"total_runtime": total_runtime, "total_runtime": total_runtime,
}, },
"episodes": response["episodes"], "episodes": response["episodes"],
"providers": providers_response.get("results", {}),
} }
@@ -643,6 +653,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["logo_path"])
providers.sort(key=lambda e: e.get("display_priority", ""))
return providers
def process_episodes(season_metadata, episodes_in_db): def process_episodes(season_metadata, episodes_in_db):
"""Process the episodes for the selected season.""" """Process the episodes for the selected season."""
episodes_metadata = [] episodes_metadata = []
@@ -724,3 +759,37 @@ def episode(media_id, season_number, episode_number):
error=not_found_error, error=not_found_error,
details=msg, 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 = [("", "No Region")]
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,19 @@ 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 = { context = {
"media": media_metadata, "media": media_metadata,
"media_type": media_type, "media_type": media_type,
"user_medias": user_medias, "user_medias": user_medias,
"current_instance": current_instance, "current_instance": current_instance,
"watch_providers": watch_providers,
} }
return render(request, "app/media_details.html", context) return render(request, "app/media_details.html", context)
@@ -275,6 +283,9 @@ def season_details(request, source, media_id, title, season_number): # noqa: AR
"media_type": MediaTypes.SEASON.value, "media_type": MediaTypes.SEASON.value,
"user_medias": user_medias, "user_medias": user_medias,
"current_instance": current_instance, "current_instance": current_instance,
"watch_providers": tmdb.filter_providers(
season_metadata.get("providers"), request.user.watch_provider_region
),
} }
return render(request, "app/media_details.html", context) return render(request, "app/media_details.html", context)

View File

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

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
class="{{ classes }}">
<path d="M17.9,17.39C17.64,16.59 16.89,16 16,16H15V13A1,1 0 0,0 14,12H8V10H10A1,1 0 0,0 11,9V7H13A2,2 0 0,0 15,5V4.59C17.93,5.77 20,8.64 20,12C20,14.08 19.2,15.97 17.9,17.39M11,19.93C7.05,19.44 4,16.08 4,12C4,11.38 4.08,10.78 4.21,10.21L9,15V16A2,2 0 0,0 11,18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -435,6 +435,23 @@
{# Media Details #} {# Media Details #}
<h2 class="text-xl font-bold mb-4">Details</h2> <h2 class="text-xl font-bold mb-4">Details</h2>
{% if 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_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"> <div class="bg-[#2a2f35] p-4 rounded-lg text-center md:text-start">
{% if media.details.items %} {% if media.details.items %}
{% for key, value in media.details.items %} {% for key, value in media.details.items %}

View File

@@ -72,6 +72,23 @@
</p> </p>
</div> </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"> <div class="mt-8 pt-4 border-t border-gray-700">
<p class="text-gray-400 text-sm"> <p class="text-gray-400 text-sm">
Version: <span class="font-mono">{{ version }}</span> Version: <span class="font-mono">{{ version }}</span>

View File

@@ -169,6 +169,26 @@
</div> </div>
</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.</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> <div class="border-t border-gray-600 my-5"></div>
{# Media Types Settings #} {# 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='', 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", help_text="Hide zero ratings from media cards",
) )
# Watch provider region
watch_provider_region = models.CharField(
max_length=5,
default="",
help_text="Region to show watch providers for",
)
# Calendar preferences # Calendar preferences
calendar_layout = models.CharField( calendar_layout = models.CharField(
max_length=20, 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 django_celery_beat.models import PeriodicTask
from app.models import Item, MediaTypes from app.models import Item, MediaTypes
from app.providers import tmdb
from users.forms import NotificationSettingsForm, PasswordChangeForm, UserUpdateForm from users.forms import NotificationSettingsForm, PasswordChangeForm, UserUpdateForm
from users.models import DateFormatChoices, QuickWatchDateChoices, TimeFormatChoices from users.models import DateFormatChoices, QuickWatchDateChoices, TimeFormatChoices
@@ -223,6 +224,7 @@ def preferences(request):
"quick_watch_date_choices": QuickWatchDateChoices.choices, "quick_watch_date_choices": QuickWatchDateChoices.choices,
"date_format_choices": DateFormatChoices.choices, "date_format_choices": DateFormatChoices.choices,
"time_format_choices": TimeFormatChoices.choices, "time_format_choices": TimeFormatChoices.choices,
"watch_provider_choices": tmdb.watch_provider_regions(),
}, },
) )
@@ -251,6 +253,7 @@ def preferences(request):
TimeFormatChoices.HOUR_24, TimeFormatChoices.HOUR_24,
) )
media_types_checked = request.POST.getlist("media_types_checkboxes") media_types_checked = request.POST.getlist("media_types_checkboxes")
request.user.watch_provider_region = request.POST.get("watch_provider_region", "")
# Update user preferences for each media type # Update user preferences for each media type
for media_type in media_types: for media_type in media_types: