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}"
params = {
**base_params,
"append_to_response": "recommendations,external_ids,credits",
"append_to_response": "recommendations,external_ids,credits,watch/providers",
}
try:
@@ -231,6 +231,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)
@@ -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):
"""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,
@@ -317,7 +323,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,
@@ -371,7 +379,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:
@@ -434,10 +442,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=None):
"""Process the metadata for the selected season from The Movie Database."""
episodes = response["episodes"]
num_episodes = len(episodes)
@@ -475,6 +484,7 @@ def process_season(response):
"total_runtime": total_runtime,
},
"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):
"""Process the episodes for the selected season."""
episodes_metadata = []
@@ -724,3 +759,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 = [("", "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 = {
"media": media_metadata,
"media_type": media_type,
"user_medias": user_medias,
"current_instance": current_instance,
"watch_providers": watch_providers,
}
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,
"user_medias": user_medias,
"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)

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,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 #}
<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">
{% 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,26 @@
</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>
{# 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",
)
# Watch provider region
watch_provider_region = models.CharField(
max_length=5,
default="",
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
@@ -223,6 +224,7 @@ def preferences(request):
"quick_watch_date_choices": QuickWatchDateChoices.choices,
"date_format_choices": DateFormatChoices.choices,
"time_format_choices": TimeFormatChoices.choices,
"watch_provider_choices": tmdb.watch_provider_regions(),
},
)
@@ -251,6 +253,7 @@ def preferences(request):
TimeFormatChoices.HOUR_24,
)
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
for media_type in media_types: