Save OMDb details and posters locally
This commit is contained in:
1 parent
1f7e88f117
commit
4f78883f0b
6 files changed
+239
-7
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"tt0050083": { "title": "12 Angry Men", "poster": "/covers/media/tt0050083.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjE4NzdmOTYtYjc5Yi00YzBiLWEzNDEtNTgxZGQ2MWVkN2NiXkEyXkFqcGc@._V1_QL75_UX380_CR0,11,380,562_.jpg", "year": "1957", "genre": "Crime, Drama", "runtime": "96 min", "plot": "The jury in a New York City murder trial is frustrated by a single member whose skeptical caution forces them to more carefully consider the evidence before jumping to a hasty verdict." },
|
||||||
|
"tt0111161": { "title": "The Shawshank Redemption", "poster": "/covers/media/tt0111161.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDAyY2FhYjctNDc5OS00MDNlLThiMGUtY2UxYWVkNGY2ZjljXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "1994", "genre": "Drama", "runtime": "142 min", "plot": "After a banker is sentenced to life in Shawshank Prison, he forms an unlikely friendship with a seasoned inmate and clings to hope amid cruelty and corruption." },
|
||||||
|
"tt0364569": { "title": "Oldboy", "poster": "/covers/media/tt0364569.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTAwNzNjYWItZmI0Ni00ZTcyLWIwNWMtZjlmNGMxZTEyYTJmXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2003", "genre": "Action, Drama, Mystery", "runtime": "120 min", "plot": "After being kidnapped and imprisoned for fifteen years, Oh Dae-Su is released, only to find that he must track down his captor in five days." },
|
||||||
|
"tt0499549": { "title": "Avatar", "poster": "/covers/media/tt0499549.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDEzMmQwZjctZWU2My00MWNlLWE0NjItMDJlYTRlNGJiZjcyXkEyXkFqcGc@._V1_QL75_UY562_CR1,0,380,562_.jpg", "year": "2009", "genre": "Action, Adventure, Fantasy", "runtime": "162 min", "plot": "A paraplegic Marine dispatched to the moon Pandora on a unique mission becomes torn between following his orders and protecting the world he feels is his home." },
|
||||||
|
"tt0816692": { "title": "Interstellar", "poster": "/covers/media/tt0816692.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYzdjMDAxZGItMjI2My00ODA1LTlkNzItOWFjMDU5ZDJlYWY3XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Adventure, Sci-Fi", "runtime": "169 min", "plot": "In a dystopian future where Earth has become near-uninhabitable, a team of astronauts embark on a mission to find a new home for humanity." },
|
||||||
|
"tt0892769": { "title": "How to Train Your Dragon", "poster": "/covers/media/tt0892769.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjA5NDQyMjc2NF5BMl5BanBnXkFtZTcwMjg5ODcyMw@@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2010", "genre": "Animation, Action, Adventure", "runtime": "98 min", "plot": "A hapless young Viking who aspires to hunt dragons becomes the unlikely friend of a young dragon himself, and learns there may be more to the creatures than he assumed." },
|
||||||
|
"tt10795658": { "title": "Alice in Borderland", "poster": "/covers/media/tt10795658.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYmMzODA4M2MtMjBmNC00YjFhLTg4NzgtODgyNzE4NWQxMThmXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2020–2025", "genre": "Action, Drama, Horror", "runtime": "50 min", "plot": "Arisu - a listless, jobless and video-game-obsessed young man - suddenly finds himself in a strange, emptied-out version of Tokyo in which he and his friends must compete in dangerous games in order to survive." },
|
||||||
|
"tt11126994": { "title": "Arcane", "poster": "/covers/media/tt11126994.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjA2NzhlMDItNWRmZC00MzRjLWE3ZjAtZjBlZDAwOWY2ODdjXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–2024", "genre": "Animation, Action, Adventure", "runtime": null, "plot": "Amid the stark discord of twin cities Piltover and Zaun, two sisters fight on rival sides of a war between magic technologies and clashing convictions." },
|
||||||
|
"tt11280740": { "title": "Severance", "poster": "/covers/media/tt11280740.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZDI5YzJhODQtMzQyNy00YWNmLWIxMjUtNDBjNjA5YWRjMzExXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2022–", "genre": "Drama, Mystery, Sci-Fi", "runtime": null, "plot": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth about their jobs." },
|
||||||
|
"tt12042730": { "title": "Project Hail Mary", "poster": "/covers/media/tt12042730.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNTkwNzJiYTctNzI3NC00NjE1LTlhYjktY2Q5MTdmMWFmNzcxXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2026", "genre": "Adventure, Comedy, Sci-Fi", "runtime": "156 min", "plot": "A science teacher wakes up alone on a spaceship. As his memory returns, he uncovers a mission to stop a mysterious substance killing Earth's sun, and realizes that an unexpected friendship may be the key." },
|
||||||
|
"tt13024974": { "title": "D.P.", "poster": "/covers/media/tt13024974.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZjE3OWU5ZDctYTE0My00ODQ4LWE1MGUtYjgwYzg1YTUxMmY3XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–", "genre": "Action, Crime, Drama", "runtime": null, "plot": "A young private's assignment to capture army deserters reveals the painful reality endured by each enlistee during his compulsory call of duty." },
|
||||||
|
"tt14403178": { "title": "Beef", "poster": "/covers/media/tt14403178.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMGNlNDkyYzItNTg5Yy00NGFiLWJhYTctODE5NjNkYjk4ZDUwXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2023–", "genre": "Comedy, Drama", "runtime": null, "plot": "Minor conflicts spiral into consuming feuds between individuals, exposing pride, resentment, and hidden pain." },
|
||||||
|
"tt1454468": { "title": "Gravity", "poster": "/covers/media/tt1454468.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNjE5MzYwMzYxMF5BMl5BanBnXkFtZTcwOTk4MTk0OQ@@._V1_QL75_UY562_CR0,0,380,562_.jpg", "year": "2013", "genre": "Drama, Sci-Fi, Thriller", "runtime": "91 min", "plot": "Two astronauts must survive in space after their shuttle is hit by debris during a spacewalk." },
|
||||||
|
"tt14688458": { "title": "Silo", "poster": "/covers/media/tt14688458.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BN2E5ODJmOTUtYTk0NS00NmFiLTk0MTgtZDRlNzE1YmExMjk4XkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2023–", "genre": "Adventure, Drama, Mystery", "runtime": null, "plot": "Men and women live in a giant silo underground with several regulations which they believe are in place to protect them from the toxic and ruined world on the surface." },
|
||||||
|
"tt15398776": { "title": "Oppenheimer", "poster": "/covers/media/tt15398776.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BN2JkMDc5MGQtZjg3YS00NmFiLWIyZmQtZTJmNTM5MjVmYTQ4XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2023", "genre": "Biography, Drama, History", "runtime": "180 min", "plot": "A dramatization of the life story of J. Robert Oppenheimer, the physicist who had a large hand in the development of the atomic bombs that brought an end to World War II." },
|
||||||
|
"tt1630029": { "title": "Avatar: The Way of Water", "poster": "/covers/media/tt1630029.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNWI0Y2NkOWEtMmM2OC00MjQ3LWI1YzItZGQxYzQ3NzI4NWZmXkEyXkFqcGc@._V1_QL75_UY562_CR6,0,380,562_.jpg", "year": "2022", "genre": "Action, Adventure, Fantasy", "runtime": "192 min", "plot": "Jake Sully lives with his newfound family formed on the extrasolar moon Pandora. Once a familiar threat returns to finish what was previously started, Jake must work with Neytiri and the army of the Na'vi race to protect their home." },
|
||||||
|
"tt1646971": { "title": "How to Train Your Dragon 2", "poster": "/covers/media/tt1646971.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMzMwMTAwODczN15BMl5BanBnXkFtZTgwMDk2NDA4MTE@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Animation, Action, Adventure", "runtime": "102 min", "plot": "When Hiccup and Toothless discover an ice cave that is home to hundreds of new wild dragons and the mysterious Dragon Rider, the two friends find themselves at the center of a battle to protect the peace." },
|
||||||
|
"tt1757678": { "title": "Avatar: Fire and Ash", "poster": "/covers/media/tt1757678.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZDYxY2I1OGMtN2Y4MS00ZmU1LTgyNDAtODA0MzAyYjI0N2Y2XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2025", "genre": "Action, Adventure, Drama", "runtime": "197 min", "plot": "Jake and Neytiri's family grapples with grief, encountering a new, aggressive Na'vi tribe, the Ash People, who are led by the fiery Varang, as the conflict on Pandora escalates and a new moral focus emerges." },
|
||||||
|
"tt1856101": { "title": "Blade Runner 2049", "poster": "/covers/media/tt1856101.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNzA1Njg4NzYxOV5BMl5BanBnXkFtZTgwODk5NjU3MzI@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2017", "genre": "Action, Drama, Mystery", "runtime": "164 min", "plot": "Young Blade Runner K's discovery of a long-buried secret leads him to track down former Blade Runner Rick Deckard, who's been missing for thirty years." },
|
||||||
|
"tt1865718": { "title": "Gravity Falls", "poster": "/covers/media/tt1865718.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTEzNDc3MDQ2NzNeQTJeQWpwZ15BbWU4MDYzMzUwMDIx._V1_QL75_UX380_CR0,15,380,562_.jpg", "year": "2012–2016", "genre": "Animation, Adventure, Comedy", "runtime": "23 min", "plot": "Twin siblings Dipper and Mabel Pines spend the summer at their great-uncle's tourist trap in the enigmatic Gravity Falls, Oregon." },
|
||||||
|
"tt2386490": { "title": "How to Train Your Dragon: The Hidden World", "poster": "/covers/media/tt2386490.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjIwMDIwNjAyOF5BMl5BanBnXkFtZTgwNDE1MDc2NTM@._V1_QL75_UX380_CR0,20,380,562_.jpg", "year": "2019", "genre": "Animation, Action, Adventure", "runtime": "104 min", "plot": "When Hiccup discovers Toothless isn't the only Night Fury, he must seek the Hidden World, a secret Dragon Utopia before a hired tyrant named Grimmel finds it first." },
|
||||||
|
"tt2397535": { "title": "Predestination", "poster": "/covers/media/tt2397535.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BY2VhODM5OTUtZDJhMi00MTc5LThjNjYtZWY1M2NlNWU0N2NjXkEyXkFqcGc@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2015", "genre": "Action, Drama, Sci-Fi", "runtime": "97 min", "plot": "As his last assignment, a temporal agent is tasked to travel back in time and prevent a bomb attack in New York in 1975. The hunt, however, turns out to be beyond the bounds of possibility." },
|
||||||
|
"tt2582802": { "title": "Whiplash", "poster": "/covers/media/tt2582802.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDFjOWFkYzktYzhhMC00NmYyLTkwY2EtYjViMDhmNzg0OGFkXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Drama, Music", "runtime": "106 min", "plot": "A promising young drummer enrolls at a cut-throat music conservatory where his dreams of greatness are mentored by an instructor who will stop at nothing to realize a student's potential." },
|
||||||
|
"tt29623480": { "title": "The Wild Robot", "poster": "/covers/media/tt29623480.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZWNiZjVlZTUtNGUwYi00MjJmLTg2MDctNWEzYTJiMzY1ODc4XkEyXkFqcGc@._V1_QL75_UX380_CR0,20,380,562_.jpg", "year": "2024", "genre": "Animation, Adventure, Family", "runtime": "102 min", "plot": "A sentient robot stranded on an uninhabited island raises an orphaned gosling while trying to fit in with the local wildlife." },
|
||||||
|
"tt30460310": { "title": "Spider-Noir", "poster": "/covers/media/tt30460310.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjU3NjEwNTItMzc1Mi00Y2QyLTljNDItOTQ4YjU4NjZjN2EyXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2026", "genre": "Action, Adventure, Crime", "runtime": null, "plot": "Ben Reilly, a seasoned, down-on-his-luck private investigator in 1930s New York, is forced to grapple with his past life, following a deeply personal tragedy, as the city's one and only superhero." },
|
||||||
|
"tt4633694": { "title": "Spider-Man: Into the Spider-Verse", "poster": "/covers/media/tt4633694.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjMwNDkxMTgzOF5BMl5BanBnXkFtZTgwNTkwNTQ3NjM@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2018", "genre": "Animation, Action, Adventure", "runtime": "117 min", "plot": "Teen Miles Morales becomes the Spider-Man of his universe and must join with five spider-powered individuals from other dimensions to stop a threat for all realities." },
|
||||||
|
"tt6741278": { "title": "Invincible", "poster": "/covers/media/tt6741278.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZGM5MmM2ZDAtMzBjNS00NjMxLWE4ZTMtNzFjMWMxYzU2ZThjXkEyXkFqcGc@._V1_QL75_UY562_CR35,0,380,562_.jpg", "year": "2021–", "genre": "Animation, Action, Adventure", "runtime": null, "plot": "When 17-year-old Mark Grayson inherits superpowers, he becomes the superhero Invincible." },
|
||||||
|
"tt8111088": { "title": "The Mandalorian", "poster": "/covers/media/tt8111088.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNjgxZGM0OWUtZGY1MS00MWRmLTk2N2ItYjQyZTI1OThlZDliXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2019–2023", "genre": "Action, Adventure, Fantasy", "runtime": "40 min", "plot": "The travels of a lone bounty hunter in the outer reaches of the galaxy, far from the authority of the New Republic." },
|
||||||
|
"tt9140554": { "title": "Loki", "poster": "/covers/media/tt9140554.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYzA2YjM2ZWQtYTZhMS00OTI3LTlhYzQtZjBiZWZkMDdlNjA5XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–2023", "genre": "Action, Adventure, Fantasy", "runtime": null, "plot": "The mercurial villain Loki resumes his role as the God of Mischief in a new series that takes place after the events of “Avengers: Endgame.”" },
|
||||||
|
"tt9253284": { "title": "Andor", "poster": "/covers/media/tt9253284.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNGI2MTJjMjUtMTJhOC00YTY2LTg1NjUtMTdmMjg4YTk2YjM5XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2022–2025", "genre": "Adventure, Drama, Fantasy", "runtime": null, "plot": "In an era filled with danger, deception, and intrigue, Cassian Andor embarks on a path that is destined to turn him into a Rebel hero." },
|
||||||
|
"tt9362722": { "title": "Spider-Man: Across the Spider-Verse", "poster": "/covers/media/tt9362722.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNThiZjA3MjItZGY5Ni00ZmJhLWEwN2EtOTBlYTA4Y2E0M2ZmXkEyXkFqcGc@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2023", "genre": "Animation, Action, Adventure", "runtime": "140 min", "plot": "Traveling across the multiverse, Miles Morales meets a new team of Spider-People, made up of heroes from different dimensions. But when the heroes clash over how to deal with a new threat, Miles finds himself at a crossroads." }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import mediaDetailsData from '#data/mediaDetails.json';
|
||||||
|
import type { MediaDetails } from './types';
|
||||||
|
|
||||||
|
interface StoredMediaDetails extends MediaDetails {
|
||||||
|
title?: string | null;
|
||||||
|
posterSource?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imdbIdPattern = /tt\d+/;
|
||||||
|
const storedDetails = mediaDetailsData as Record<string, StoredMediaDetails>;
|
||||||
|
|
||||||
|
function toMediaDetails(stored: StoredMediaDetails): MediaDetails {
|
||||||
|
return {
|
||||||
|
poster: stored.poster ?? null,
|
||||||
|
year: stored.year ?? null,
|
||||||
|
genre: stored.genre ?? null,
|
||||||
|
runtime: stored.runtime ?? null,
|
||||||
|
plot: stored.plot ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storedDetailsFor(urls: string[]): Record<string, MediaDetails> {
|
||||||
|
const entries = urls.flatMap((url) => {
|
||||||
|
const stored = storedDetails[url.match(imdbIdPattern)?.[0] ?? ''];
|
||||||
|
return stored ? [[url, toMediaDetails(stored)] as const] : [];
|
||||||
|
});
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
import { about } from '#lib/content';
|
import { about } from '#lib/content';
|
||||||
import { getAniListProfile } from '#lib/server/anilist';
|
import { getAniListProfile } from '#lib/server/anilist';
|
||||||
import { getMediaDetails } from '#lib/server/omdb';
|
import { getMediaDetails } from '#lib/server/omdb';
|
||||||
|
import { storedDetailsFor } from '#lib/media/stored';
|
||||||
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
||||||
const anilistUsername = about.anilistUsername;
|
const anilistUsername = about.anilistUsername;
|
||||||
|
const storedDetails = storedDetailsFor(mediaUrls);
|
||||||
|
const unsavedUrls = mediaUrls.filter((url) => !storedDetails[url]);
|
||||||
|
|
||||||
const [anime, manga, mediaDetails, listening] = await Promise.all([
|
const [anime, manga, fetchedDetails, listening] = await Promise.all([
|
||||||
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
|
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
|
||||||
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
|
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
|
||||||
getMediaDetails(mediaUrls),
|
unsavedUrls.length ? getMediaDetails(unsavedUrls) : {},
|
||||||
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
|
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasManga = !!manga?.statusCounts.some((item) => item.count > 0);
|
const hasManga = !!manga?.statusCounts.some((item) => item.count > 0);
|
||||||
|
|
||||||
|
const mediaDetails = { ...fetchedDetails, ...storedDetails };
|
||||||
|
|
||||||
return { anime, manga: hasManga ? manga : null, mediaDetails, listening };
|
return { anime, manga: hasManga ? manga : null, mediaDetails, listening };
|
||||||
};
|
};
|
||||||
+29
-5
@@ -1,5 +1,6 @@
|
|||||||
import imdb
|
import imdb
|
||||||
import jsonfile
|
import jsonfile
|
||||||
|
import media_cache
|
||||||
|
|
||||||
ABOUT_FILE = "about.json"
|
ABOUT_FILE = "about.json"
|
||||||
MEDIA_KINDS = {
|
MEDIA_KINDS = {
|
||||||
@@ -52,11 +53,13 @@ def find_index(items: list[dict], text: str) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def look_up_name(identifier: str) -> str:
|
def details_for(identifier: str, name: str | None) -> dict:
|
||||||
details = imdb.lookup(identifier)
|
try:
|
||||||
if not details:
|
return media_cache.cached_details(identifier)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
if name:
|
||||||
|
return {}
|
||||||
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
||||||
return details.get("Title") or identifier
|
|
||||||
|
|
||||||
|
|
||||||
def with_optional(item: dict, key: str, value) -> dict:
|
def with_optional(item: dict, key: str, value) -> dict:
|
||||||
@@ -83,7 +86,8 @@ def add_item(kind: str, text: str, rating: int | None, name: str | None = None,
|
|||||||
data, items = load_items(kind)
|
data, items = load_items(kind)
|
||||||
if find_index(items, identifier) is not None:
|
if find_index(items, identifier) is not None:
|
||||||
raise ValueError(f"{identifier} is already listed")
|
raise ValueError(f"{identifier} is already listed")
|
||||||
item = build_item(name or look_up_name(identifier), imdb.title_url(identifier), rating, status, collection)
|
details = details_for(identifier, name)
|
||||||
|
item = build_item(name or details.get("title") or identifier, imdb.title_url(identifier), rating, status, collection)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
jsonfile.save(ABOUT_FILE, data)
|
jsonfile.save(ABOUT_FILE, data)
|
||||||
return item
|
return item
|
||||||
@@ -106,6 +110,9 @@ def remove_item(kind: str, text: str) -> dict:
|
|||||||
raise ValueError(f"{text} is not listed")
|
raise ValueError(f"{text} is not listed")
|
||||||
removed = items.pop(index)
|
removed = items.pop(index)
|
||||||
jsonfile.save(ABOUT_FILE, data)
|
jsonfile.save(ABOUT_FILE, data)
|
||||||
|
identifier = imdb.imdb_id(removed.get("url", ""))
|
||||||
|
if identifier:
|
||||||
|
media_cache.remove_details(identifier)
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -121,3 +128,20 @@ def set_status(kind: str, text: str, status: str) -> dict:
|
|||||||
|
|
||||||
def set_collection(kind: str, text: str, collection: str | None) -> dict:
|
def set_collection(kind: str, text: str, collection: str | None) -> dict:
|
||||||
return update_item(kind, text, lambda item: with_optional(item, "collection", collection))
|
return update_item(kind, text, lambda item: with_optional(item, "collection", collection))
|
||||||
|
|
||||||
|
|
||||||
|
def item_identifiers(kind: str) -> list[str]:
|
||||||
|
_, items = load_items(kind)
|
||||||
|
return [identifier for identifier in (imdb.imdb_id(item.get("url", "")) for item in items) if identifier]
|
||||||
|
|
||||||
|
|
||||||
|
def missing_details(kind: str) -> list[str]:
|
||||||
|
return media_cache.missing_identifiers(item_identifiers(kind))
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_details(identifier: str) -> dict:
|
||||||
|
return media_cache.cached_details(identifier, fresh=True)
|
||||||
|
|
||||||
|
|
||||||
|
def stored_details(identifier: str) -> dict | None:
|
||||||
|
return media_cache.load_details().get(identifier)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import imdb
|
||||||
|
import jsonfile
|
||||||
|
from jsonfile import PROJECT_ROOT
|
||||||
|
|
||||||
|
DETAILS_FILE = "mediaDetails.json"
|
||||||
|
POSTERS_DIR = PROJECT_ROOT / "static" / "covers" / "media"
|
||||||
|
POSTERS_PUBLIC_PATH = "/covers/media"
|
||||||
|
MISSING_VALUE = "N/A"
|
||||||
|
REQUEST_HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_details() -> dict[str, dict]:
|
||||||
|
return jsonfile.load_data(DETAILS_FILE, {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_details(details: dict[str, dict]) -> None:
|
||||||
|
jsonfile.save_data(DETAILS_FILE, dict(sorted(details.items())))
|
||||||
|
|
||||||
|
|
||||||
|
def present(value: str | None) -> str | None:
|
||||||
|
return value if value and value != MISSING_VALUE else None
|
||||||
|
|
||||||
|
|
||||||
|
def poster_path(identifier: str):
|
||||||
|
return POSTERS_DIR / f"{identifier}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def download_poster(identifier: str, source_url: str | None) -> str | None:
|
||||||
|
if not source_url:
|
||||||
|
return None
|
||||||
|
request = urllib.request.Request(source_url, headers=REQUEST_HEADERS)
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
image = response.read()
|
||||||
|
POSTERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
poster_path(identifier).write_bytes(image)
|
||||||
|
return f"{POSTERS_PUBLIC_PATH}/{identifier}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def has_local_poster(identifier: str, stored: dict) -> bool:
|
||||||
|
poster = stored.get("poster")
|
||||||
|
return poster is None or (poster.startswith(POSTERS_PUBLIC_PATH) and poster_path(identifier).exists())
|
||||||
|
|
||||||
|
|
||||||
|
def has_details(identifier: str) -> bool:
|
||||||
|
stored = load_details().get(identifier)
|
||||||
|
return bool(stored) and has_local_poster(identifier, stored)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_details(identifier: str) -> dict:
|
||||||
|
result = imdb.lookup(identifier)
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"Could not look up {identifier} on OMDb. Check OMDB_API_KEY in .env")
|
||||||
|
|
||||||
|
poster_source = present(result.get("Poster"))
|
||||||
|
try:
|
||||||
|
poster = download_poster(identifier, poster_source)
|
||||||
|
except OSError:
|
||||||
|
poster = poster_source
|
||||||
|
|
||||||
|
stored = {
|
||||||
|
"title": present(result.get("Title")),
|
||||||
|
"poster": poster,
|
||||||
|
"posterSource": poster_source,
|
||||||
|
"year": present(result.get("Year")),
|
||||||
|
"genre": present(result.get("Genre")),
|
||||||
|
"runtime": present(result.get("Runtime")),
|
||||||
|
"plot": present(result.get("Plot")),
|
||||||
|
}
|
||||||
|
details = load_details()
|
||||||
|
details[identifier] = stored
|
||||||
|
save_details(details)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def cached_details(identifier: str, fresh: bool = False) -> dict:
|
||||||
|
if not fresh and has_details(identifier):
|
||||||
|
return load_details()[identifier]
|
||||||
|
return fetch_details(identifier)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_details(identifier: str) -> None:
|
||||||
|
details = load_details()
|
||||||
|
if details.pop(identifier, None) is not None:
|
||||||
|
save_details(details)
|
||||||
|
if poster_path(identifier).exists():
|
||||||
|
poster_path(identifier).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def missing_identifiers(identifiers: list[str]) -> list[str]:
|
||||||
|
return [identifier for identifier in identifiers if not has_details(identifier)]
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from textual import work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import DataTable
|
from textual.widgets import DataTable
|
||||||
|
|
||||||
import imdb
|
import imdb
|
||||||
import media
|
import media
|
||||||
|
import media_cache
|
||||||
|
from tui.cache_indicator import cache_state, cache_text
|
||||||
from tui.content_pane import ContentPane
|
from tui.content_pane import ContentPane
|
||||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||||
|
|
||||||
|
REQUEST_DELAY_SECONDS = 0.5
|
||||||
STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"}
|
STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"}
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +28,8 @@ class MediaPane(ContentPane):
|
|||||||
Binding("s", "status", "Status"),
|
Binding("s", "status", "Status"),
|
||||||
Binding("g", "collection", "Group"),
|
Binding("g", "collection", "Group"),
|
||||||
Binding("d", "delete", "Delete"),
|
Binding("d", "delete", "Delete"),
|
||||||
|
Binding("u", "refresh", "Refresh"),
|
||||||
|
Binding("C", "cache_all", "Cache all"),
|
||||||
Binding("r", "reload", "Reload"),
|
Binding("r", "reload", "Reload"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -42,20 +50,27 @@ class MediaPane(ContentPane):
|
|||||||
table.add_column("Rating", key="rating")
|
table.add_column("Rating", key="rating")
|
||||||
table.add_column("Title", key="title")
|
table.add_column("Title", key="title")
|
||||||
table.add_column("Group", key="collection")
|
table.add_column("Group", key="collection")
|
||||||
|
table.add_column("Year", key="year")
|
||||||
|
table.add_column("Cache", key="cache")
|
||||||
table.add_column("IMDb", key="imdb")
|
table.add_column("IMDb", key="imdb")
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
def reload(self, focus_key: str | None = None):
|
def reload(self, focus_key: str | None = None):
|
||||||
table = self.table()
|
table = self.table()
|
||||||
table.clear()
|
table.clear()
|
||||||
|
stored = media_cache.load_details()
|
||||||
for item in media.list_items(self.kind):
|
for item in media.list_items(self.kind):
|
||||||
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
||||||
status = media.status_of(item)
|
status = media.status_of(item)
|
||||||
|
details = stored.get(identifier)
|
||||||
|
state = cache_state(bool(details), bool(details) and media_cache.has_local_poster(identifier, details))
|
||||||
table.add_row(
|
table.add_row(
|
||||||
Text(status, style=STATUS_STYLES[status]),
|
Text(status, style=STATUS_STYLES[status]),
|
||||||
rating_text(item),
|
rating_text(item),
|
||||||
item.get("name", ""),
|
item.get("name", ""),
|
||||||
item.get("collection", ""),
|
item.get("collection", ""),
|
||||||
|
(details or {}).get("year") or "-",
|
||||||
|
cache_text(state),
|
||||||
identifier,
|
identifier,
|
||||||
key=identifier,
|
key=identifier,
|
||||||
)
|
)
|
||||||
@@ -148,3 +163,38 @@ class MediaPane(ContentPane):
|
|||||||
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}")
|
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}")
|
||||||
|
|
||||||
self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed)
|
self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed)
|
||||||
|
|
||||||
|
def action_refresh(self):
|
||||||
|
item = self.selected_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
identifier = imdb.imdb_id(item["url"])
|
||||||
|
|
||||||
|
def refreshed(details: dict):
|
||||||
|
self.reload(identifier)
|
||||||
|
self.notify(f"Saved details and poster for {item['name']}")
|
||||||
|
|
||||||
|
self.run_in_background(f"Looking up {item['name']} on OMDb...", lambda: media.refresh_details(identifier), refreshed)
|
||||||
|
|
||||||
|
def action_cache_all(self):
|
||||||
|
missing = media.missing_details(self.kind)
|
||||||
|
if not missing:
|
||||||
|
self.notify(f"Every {self.label} already has saved details and a poster")
|
||||||
|
return
|
||||||
|
self.notify(f"Saving details for {len(missing)} titles...")
|
||||||
|
self.cache_missing_details(missing)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="media-cache")
|
||||||
|
def cache_missing_details(self, missing: list[str]):
|
||||||
|
saved = 0
|
||||||
|
for index, identifier in enumerate(missing):
|
||||||
|
if index:
|
||||||
|
time.sleep(REQUEST_DELAY_SECONDS)
|
||||||
|
try:
|
||||||
|
media.refresh_details(identifier)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.app.call_from_thread(self.notify, f"{identifier}: {error}", title="Skipped", severity="warning")
|
||||||
|
continue
|
||||||
|
saved += 1
|
||||||
|
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
||||||
|
self.app.call_from_thread(self.notify, f"Saved {saved} of {len(missing)} titles")
|
||||||
Reference in new issue
Block a user