Files
media-episode-viewer/mac_cocoa.py
T

341 lines
14 KiB
Python

#!/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"))
user_id = os.environ.get("JELLYFIN_USER_ID")
if not user_id:
raise RuntimeError("JELLYFIN_USER_ID not set in environment")
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)
previous_episode = prev_ep_info["episode_number"]
total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155))
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}
# Update UI on main thread
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()