|
| 1 | +import threading |
| 2 | +import webbrowser |
| 3 | +import tkinter as tk |
| 4 | +from tkinter import messagebox |
| 5 | +from dataclasses import dataclass |
| 6 | +from typing import List, Tuple, Optional, Dict |
| 7 | +import requests |
| 8 | +import io |
| 9 | +from PIL import Image, ImageTk # pip install pillow |
| 10 | + |
| 11 | +import ttkbootstrap as tb |
| 12 | +from ttkbootstrap.constants import * |
| 13 | +from ttkbootstrap.widgets.scrolled import ScrolledText |
| 14 | + |
| 15 | +# ---------------- CONFIG ---------------- # |
| 16 | +RESULTS_PER_PAGE = 6 |
| 17 | +OMDB_API_KEY = "YOUR_OMDB_API_KEY" # ← replace with your OMDb API key |
| 18 | +OMDB_SEARCH_URL = "http://www.omdbapi.com/" |
| 19 | + |
| 20 | +# ---------------- GLOBAL STATE ---------------- # |
| 21 | +all_ranked_movies: List[Tuple["Movie", float]] = [] |
| 22 | +current_page = 1 |
| 23 | +poster_cache: Dict[str, ImageTk.PhotoImage] = {} |
| 24 | + |
| 25 | +# ---------------- DATA STRUCTURE ---------------- # |
| 26 | +@dataclass |
| 27 | +class Movie: |
| 28 | + title: str |
| 29 | + url: str |
| 30 | + display_url: str |
| 31 | + description: str |
| 32 | + poster_url: str |
| 33 | + |
| 34 | +# ---------------- SEARCH LOGIC ---------------- # |
| 35 | +def fetch_movies(query: str) -> List[Movie]: |
| 36 | + """Fetch movies from OMDb API matching the query.""" |
| 37 | + movies: List[Movie] = [] |
| 38 | + try: |
| 39 | + params = {"apikey": OMDB_API_KEY, "s": query, "type": "movie"} |
| 40 | + resp = requests.get(OMDB_SEARCH_URL, params=params, timeout=10) |
| 41 | + data = resp.json() |
| 42 | + |
| 43 | + if data.get("Response") != "True": |
| 44 | + return [] |
| 45 | + |
| 46 | + for item in data.get("Search", []): |
| 47 | + imdb_id = item.get("imdbID") |
| 48 | + detail_resp = requests.get( |
| 49 | + OMDB_SEARCH_URL, |
| 50 | + params={"apikey": OMDB_API_KEY, "i": imdb_id, "plot": "short"}, |
| 51 | + timeout=10 |
| 52 | + ).json() |
| 53 | + |
| 54 | + movies.append( |
| 55 | + Movie( |
| 56 | + title=detail_resp.get("Title", "Unknown"), |
| 57 | + url=f"https://www.imdb.com/title/{imdb_id}/", |
| 58 | + display_url=f"imdb.com › {imdb_id}", |
| 59 | + description=detail_resp.get("Plot", ""), |
| 60 | + poster_url=detail_resp.get("Poster", "") |
| 61 | + ) |
| 62 | + ) |
| 63 | + except requests.RequestException as e: |
| 64 | + messagebox.showerror("API Error", f"Network error: {e}") |
| 65 | + except Exception as e: |
| 66 | + messagebox.showerror("Error", str(e)) |
| 67 | + return movies |
| 68 | + |
| 69 | +def rank_movies(query: str, movies: List[Movie]) -> List[Tuple[Movie, float]]: |
| 70 | + """Placeholder ranking logic; can implement TF-IDF or other metrics.""" |
| 71 | + return [(m, 0.0) for m in movies] |
| 72 | + |
| 73 | +# ---------------- UI HELPERS ---------------- # |
| 74 | +def open_url(url: str): |
| 75 | + webbrowser.open_new_tab(url) |
| 76 | + |
| 77 | +def load_image(url: str, size=(100, 150)) -> Optional[ImageTk.PhotoImage]: |
| 78 | + """Load poster image from URL, with caching.""" |
| 79 | + if not url or url == "N/A": |
| 80 | + return None |
| 81 | + if url in poster_cache: |
| 82 | + return poster_cache[url] |
| 83 | + try: |
| 84 | + resp = requests.get(url, timeout=10) |
| 85 | + img = Image.open(io.BytesIO(resp.content)) |
| 86 | + img = img.resize(size, Image.ANTIALIAS) |
| 87 | + photo = ImageTk.PhotoImage(img) |
| 88 | + poster_cache[url] = photo |
| 89 | + return photo |
| 90 | + except Exception: |
| 91 | + return None |
| 92 | + |
| 93 | +def display_page(): |
| 94 | + text.configure(state="normal") |
| 95 | + text.delete("1.0", "end") |
| 96 | + |
| 97 | + start = (current_page - 1) * RESULTS_PER_PAGE |
| 98 | + end = start + RESULTS_PER_PAGE |
| 99 | + page_results = all_ranked_movies[start:end] |
| 100 | + |
| 101 | + if not page_results: |
| 102 | + text.insert("end", "No results found.\n") |
| 103 | + text.configure(state="disabled") |
| 104 | + update_pagination() |
| 105 | + return |
| 106 | + |
| 107 | + for idx, (movie, _) in enumerate(page_results): |
| 108 | + # Movie Title |
| 109 | + text.insert("end", f"{movie.title}\n", f"title_{idx}") |
| 110 | + text.tag_config(f"title_{idx}", foreground="#1a0dab", font=("Segoe UI", 14, "bold")) |
| 111 | + text.tag_bind(f"title_{idx}", "<Double-Button-1>", lambda e, url=movie.url: open_url(url)) |
| 112 | + |
| 113 | + # Display URL |
| 114 | + text.insert("end", f"{movie.display_url}\n", f"url_{idx}") |
| 115 | + text.tag_config(f"url_{idx}", foreground="#006621", font=("Segoe UI", 10)) |
| 116 | + |
| 117 | + # Poster |
| 118 | + poster = load_image(movie.poster_url) |
| 119 | + if poster: |
| 120 | + text.image_create("end", image=poster) |
| 121 | + text.insert("end", "\n") |
| 122 | + |
| 123 | + # Description |
| 124 | + text.insert("end", f"{movie.description}\n\n") |
| 125 | + |
| 126 | + text.configure(state="disabled") |
| 127 | + update_pagination() |
| 128 | + |
| 129 | +# ---------------- PAGINATION ---------------- # |
| 130 | +def next_page(): |
| 131 | + global current_page |
| 132 | + if current_page * RESULTS_PER_PAGE < len(all_ranked_movies): |
| 133 | + current_page += 1 |
| 134 | + display_page() |
| 135 | + |
| 136 | +def prev_page(): |
| 137 | + global current_page |
| 138 | + if current_page > 1: |
| 139 | + current_page -= 1 |
| 140 | + display_page() |
| 141 | + |
| 142 | +def update_pagination(): |
| 143 | + total_pages = max(1, (len(all_ranked_movies) - 1) // RESULTS_PER_PAGE + 1) |
| 144 | + page_label.config(text=f"Page {current_page} of {total_pages}") |
| 145 | + prev_btn.config(state=DISABLED if current_page == 1 else NORMAL) |
| 146 | + next_btn.config(state=DISABLED if current_page == total_pages else NORMAL) |
| 147 | + |
| 148 | +# ---------------- SEARCH ---------------- # |
| 149 | +def perform_search(): |
| 150 | + query = query_entry.get().strip() |
| 151 | + if not query: |
| 152 | + messagebox.showwarning("Input Required", "Enter a movie title.") |
| 153 | + return |
| 154 | + threading.Thread(target=search_thread, args=(query,), daemon=True).start() |
| 155 | + |
| 156 | +def search_thread(query: str): |
| 157 | + global all_ranked_movies, current_page |
| 158 | + current_page = 1 |
| 159 | + all_movies = fetch_movies(query) |
| 160 | + all_ranked_movies = rank_movies(query, all_movies) |
| 161 | + display_page() |
| 162 | + |
| 163 | +# ---------------- UI SETUP ---------------- # |
| 164 | +app = tb.Window(title="Live Movie Search", themename="flatly", size=(980, 720), resizable=(True, True)) |
| 165 | + |
| 166 | +# Top frame |
| 167 | +top = tb.Frame(app, padding=15) |
| 168 | +top.pack(fill=tk.X) |
| 169 | +tb.Label(top, text="Search Movies (Live)", font=("Segoe UI", 16, "bold")).pack(anchor=tk.W) |
| 170 | + |
| 171 | +query_entry = tb.Entry(top, font=("Segoe UI", 12)) |
| 172 | +query_entry.pack(fill=tk.X, pady=8) |
| 173 | +query_entry.bind("<Return>", lambda e: perform_search()) |
| 174 | + |
| 175 | +tb.Button(top, text="Search", bootstyle="primary", command=perform_search).pack(anchor=tk.E) |
| 176 | + |
| 177 | +# Results frame |
| 178 | +result_frame = tb.Frame(app, padding=(15, 5)) |
| 179 | +result_frame.pack(fill=tk.BOTH, expand=True) |
| 180 | + |
| 181 | +result_box = ScrolledText(result_frame, autohide=True) |
| 182 | +result_box.pack(fill=tk.BOTH, expand=True) |
| 183 | +text = result_box.text |
| 184 | +text.configure(state="disabled", wrap="word") |
| 185 | + |
| 186 | +# Navigation |
| 187 | +nav = tb.Frame(app, padding=10) |
| 188 | +nav.pack(fill=tk.X) |
| 189 | + |
| 190 | +prev_btn = tb.Button(nav, text="← Prev", bootstyle="secondary", command=prev_page) |
| 191 | +prev_btn.pack(side=tk.LEFT) |
| 192 | + |
| 193 | +page_label = tb.Label(nav, text="Page 1", font=("Segoe UI", 10)) |
| 194 | +page_label.pack(side=tk.LEFT, padx=10) |
| 195 | + |
| 196 | +next_btn = tb.Button(nav, text="Next →", bootstyle="secondary", command=next_page) |
| 197 | +next_btn.pack(side=tk.LEFT) |
| 198 | + |
| 199 | +app.mainloop() |
0 commit comments