#!/usr/bin/env python3 """ Cocoa-native GUI using PyObjC to generate the One Piece progress report without a web server. This is a minimal native UI: Generate Report, Copy to Clipboard, and Debug output. """ import os import threading import json from pathlib import Path from datetime import datetime from app import current_week, format_date_range, get_latest_sunday, get_previous_sunday from clients import jellyfin import objc from AppKit import * CONFIG_PATH = str(Path.home() / ".media_episodes_viewer_config.json") def load_config(): try: with open(CONFIG_PATH, "r") as f: return json.load(f) except Exception: return {} def save_config(cfg: dict) -> bool: try: with open(CONFIG_PATH, "w") as f: json.dump(cfg, f) # update process environment so jellyfin client picks values up if cfg.get("host"): os.environ["JELLYFIN_HOST"] = cfg.get("host").rstrip("/") if cfg.get("key"): os.environ["JELLYFIN_API_KEY"] = cfg.get("key") if cfg.get("user"): os.environ["JELLYFIN_USER_ID"] = cfg.get("user") if cfg.get("total_episodes"): os.environ["TOTAL_EPISODES"] = str(cfg.get("total_episodes")) return True except Exception: return False class AppDelegate(NSObject): def applicationDidFinishLaunching_(self, notification): # Create main window rect = NSMakeRect(0.0, 0.0, 900.0, 650.0) style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable) self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(rect, style, NSBackingStoreBuffered, False) self.window.center() self.window.setTitle_("One Piece Progress Reporter") content = self.window.contentView() # Title title = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 610, 600, 24)) title.setStringValue_("🏴‍☠️ One Piece Progress Reporter") title.setBezeled_(False) title.setDrawsBackground_(False) title.setEditable_(False) title.setSelectable_(False) content.addSubview_(title) # Generate button gen_btn = NSButton.alloc().initWithFrame_(NSMakeRect(760, 610, 120, 30)) gen_btn.setTitle_("Generate Report") gen_btn.setTarget_(self) gen_btn.setAction_("generateReport:") content.addSubview_(gen_btn) # Preferences button prefs_btn = NSButton.alloc().initWithFrame_(NSMakeRect(620, 610, 120, 30)) prefs_btn.setTitle_("Preferences...") prefs_btn.setTarget_(self) prefs_btn.setAction_("openPreferences:") content.addSubview_(prefs_btn) # Report label and box report_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 580, 200, 18)) report_label.setStringValue_("Report:") report_label.setBezeled_(False) report_label.setDrawsBackground_(False) report_label.setEditable_(False) report_label.setSelectable_(False) content.addSubview_(report_label) report_scroll = NSScrollView.alloc().initWithFrame_(NSMakeRect(20, 420, 860, 150)) report_scroll.setHasVerticalScroller_(True) report_view = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, 860, 150)) report_view.setEditable_(False) report_scroll.setDocumentView_(report_view) content.addSubview_(report_scroll) self.report_view = report_view # Copy button copy_btn = NSButton.alloc().initWithFrame_(NSMakeRect(760, 360, 120, 30)) copy_btn.setTitle_("Copy to Clipboard") copy_btn.setTarget_(self) copy_btn.setAction_("copyReport:") content.addSubview_(copy_btn) # Debug label and box debug_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 390, 200, 18)) debug_label.setStringValue_("Debug:") debug_label.setBezeled_(False) debug_label.setDrawsBackground_(False) debug_label.setEditable_(False) debug_label.setSelectable_(False) content.addSubview_(debug_label) debug_scroll = NSScrollView.alloc().initWithFrame_(NSMakeRect(20, 40, 860, 340)) debug_scroll.setHasVerticalScroller_(True) debug_view = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, 860, 340)) debug_view.setEditable_(False) debug_scroll.setDocumentView_(debug_view) content.addSubview_(debug_scroll) self.debug_view = debug_view # Initial debug text try: self.debug_view.setString_(f"LATEST_SUNDAY={get_latest_sunday()}\nPREVIOUS_SUNDAY={get_previous_sunday()}\n") except Exception: self.debug_view.setString_("(Could not compute initial debug values)\n") self.window.makeKeyAndOrderFront_(None) NSApp.activateIgnoringOtherApps_(True) # Button actions def generateReport_(self, sender): # Run the heavy work in a background thread and marshal UI updates back to the main thread threading.Thread(target=self._generate_worker, daemon=True).start() def _generate_worker(self): try: jellyfin.clear_debug_logs() latest = get_latest_sunday() previous = get_previous_sunday() latest_dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) previous_dt = datetime.fromisoformat(previous.replace("Z", "+00:00")) host = os.environ.get("JELLYFIN_HOST") key = os.environ.get("JELLYFIN_API_KEY") user_id = os.environ.get("JELLYFIN_USER_ID") total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155)) # If credentials are missing, provide a demo report so UI can be validated without Jellyfin if not (host and key and user_id): demo_ep = min(total_episodes, 1000) prev_ep = max(0, demo_ep - 3) watched_this_week = demo_ep - prev_ep week_start, week_end, _ = current_week() date_range = format_date_range(week_start, week_end) report_text = ( f"ONE PIECE PROGRESS (DEMO MODE) ({date_range})\n" f"Episode {demo_ep}/{total_episodes}\n" f"Around {watched_this_week} episodes watched this week!" ) payload = {"report": report_text, "debug": "Demo mode: Jellyfin credentials not set."} self.performSelectorOnMainThread_withObject_waitUntilDone_("updateUI:", payload, False) return # Try real Jellyfin calls; on failure show error (with debug logs) try: ep_info = jellyfin.find_last_one_piece_episode(latest_dt, user_id) prev_ep_info = jellyfin.find_last_one_piece_before_previous_sunday(previous_dt, user_id) except Exception as e: payload = {"error": "Failed to contact Jellyfin: " + str(e), "debug": jellyfin.get_debug_logs()} self.performSelectorOnMainThread_withObject_waitUntilDone_("showError:", payload, False) return previous_episode = prev_ep_info["episode_number"] watched_this_week = ep_info["episode_number"] - previous_episode week_start, week_end, _ = current_week() date_range = format_date_range(week_start, week_end) report_text = ( f"ONE PIECE PROGRESS!!! ({date_range})\n" f"Episode {ep_info['episode_number']}/{total_episodes}\n" f"Around {watched_this_week} episodes watched this week!" ) debug_output = jellyfin.get_debug_logs() payload = {"report": report_text, "debug": debug_output} self.performSelectorOnMainThread_withObject_waitUntilDone_("updateUI:", payload, False) except Exception as e: payload = {"error": str(e), "debug": jellyfin.get_debug_logs()} self.performSelectorOnMainThread_withObject_waitUntilDone_("showError:", payload, False) def updateUI_(self, payload): report = payload.get("report", "") debug = payload.get("debug", "") try: self.report_view.setString_(report) self.debug_view.setString_(debug) except Exception: pass def showError_(self, payload): error = payload.get("error", "Unknown error") debug = payload.get("debug", "") alert = NSAlert.alloc().init() alert.setMessageText_("Error") alert.setInformativeText_(error) alert.runModal() try: self.debug_view.setString_(debug) except Exception: pass def copyReport_(self, sender): try: s = self.report_view.string() or "" pb = NSPasteboard.generalPasteboard() pb.clearContents() # Use public.utf8-plain-text which is commonly accepted pb.setString_forType_(s, "public.utf8-plain-text") alert = NSAlert.alloc().init() alert.setMessageText_("Copied") alert.setInformativeText_("Report copied to clipboard.") alert.runModal() except Exception as e: alert = NSAlert.alloc().init() alert.setMessageText_("Copy failed") alert.setInformativeText_(str(e)) alert.runModal() def openPreferences_(self, sender): try: prefs_rect = NSMakeRect(0.0, 0.0, 480.0, 220.0) self.prefs_window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(prefs_rect, (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable), NSBackingStoreBuffered, False) self.prefs_window.center() self.prefs_window.setTitle_("Preferences") content = self.prefs_window.contentView() # Host host_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 170, 120, 18)) host_label.setStringValue_("Jellyfin Host:") host_label.setBezeled_(False) host_label.setDrawsBackground_(False) host_label.setEditable_(False) host_label.setSelectable_(False) content.addSubview_(host_label) host_field = NSTextField.alloc().initWithFrame_(NSMakeRect(150, 165, 300, 24)) host_field.setStringValue_(os.environ.get("JELLYFIN_HOST", "")) content.addSubview_(host_field) self.prefs_host = host_field # API Key api_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 130, 120, 18)) api_label.setStringValue_("API Key:") api_label.setBezeled_(False) api_label.setDrawsBackground_(False) api_label.setEditable_(False) api_label.setSelectable_(False) content.addSubview_(api_label) api_field = NSTextField.alloc().initWithFrame_(NSMakeRect(150, 125, 300, 24)) api_field.setStringValue_(os.environ.get("JELLYFIN_API_KEY", "")) content.addSubview_(api_field) self.prefs_api_key = api_field # User ID user_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 90, 120, 18)) user_label.setStringValue_("User ID:") user_label.setBezeled_(False) user_label.setDrawsBackground_(False) user_label.setEditable_(False) user_label.setSelectable_(False) content.addSubview_(user_label) user_field = NSTextField.alloc().initWithFrame_(NSMakeRect(150, 85, 300, 24)) user_field.setStringValue_(os.environ.get("JELLYFIN_USER_ID", "")) content.addSubview_(user_field) self.prefs_user_id = user_field # Total episodes total_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 50, 120, 18)) total_label.setStringValue_("Total Episodes:") total_label.setBezeled_(False) total_label.setDrawsBackground_(False) total_label.setEditable_(False) total_label.setSelectable_(False) content.addSubview_(total_label) total_field = NSTextField.alloc().initWithFrame_(NSMakeRect(150, 45, 100, 24)) total_field.setStringValue_(os.environ.get("TOTAL_EPISODES", "1155")) content.addSubview_(total_field) self.prefs_total_episodes = total_field # Save button save_btn = NSButton.alloc().initWithFrame_(NSMakeRect(340, 10, 120, 30)) save_btn.setTitle_("Save") save_btn.setTarget_(self) save_btn.setAction_("savePreferences:") content.addSubview_(save_btn) self.prefs_window.makeKeyAndOrderFront_(None) NSApp.activateIgnoringOtherApps_(True) except Exception as e: alert = NSAlert.alloc().init() alert.setMessageText_("Prefs failed") alert.setInformativeText_(str(e)) alert.runModal() def savePreferences_(self, sender): try: host = (self.prefs_host.string() or "").strip() key = (self.prefs_api_key.string() or "").strip() user = (self.prefs_user_id.string() or "").strip() total = (self.prefs_total_episodes.string() or "").strip() cfg = {} if host: cfg["host"] = host if key: cfg["key"] = key if user: cfg["user"] = user if total: try: cfg["total_episodes"] = int(total) except Exception: pass ok = save_config(cfg) if ok: alert = NSAlert.alloc().init() alert.setMessageText_("Saved") alert.setInformativeText_("Preferences saved.") alert.runModal() try: self.debug_view.setString_(f"Saved config: host={cfg.get('host')}, user={cfg.get('user')}, total={cfg.get('total_episodes')}\n" + (self.debug_view.string() or "")) except Exception: pass try: self.prefs_window.close() except Exception: pass else: alert = NSAlert.alloc().init() alert.setMessageText_("Save failed") alert.setInformativeText_("Could not write config file") alert.runModal() except Exception as e: alert = NSAlert.alloc().init() alert.setMessageText_("Save failed") alert.setInformativeText_(str(e)) alert.runModal() def main(): app = NSApplication.sharedApplication() delegate = AppDelegate.alloc().init() app.setDelegate_(delegate) app.run() if __name__ == '__main__': main()