mirror of
https://github.com/FuzzyGrim/Yamtrack.git
synced 2026-03-03 00:27:02 +00:00
Show watch providers on media details page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
7
src/templates/app/icons/globe.svg
Normal file
7
src/templates/app/icons/globe.svg
Normal 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 |
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
18
src/users/migrations/0050_user_watch_provider_region.py
Normal file
18
src/users/migrations/0050_user_watch_provider_region.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user