Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@ vlc/
# yt-dlp executable (download from https://github.com/yt-dlp/yt-dlp/releases)
yt-dlp.exe

# YouTube OAuth client (download from Google Cloud Console; never commit)
platforms/youtube/client_secret.json

todo.txt
60 changes: 60 additions & 0 deletions GUI/account_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ def __init__(self,account):
self.notebook.AddPage(self.timelines_panel, "Timelines")
self.alias_panel = AliasPanel(self.account, self.notebook)
self.notebook.AddPage(self.alias_panel, "Aliases")
# Templates tab - currently YouTube-specific (post display template)
self.templates_panel = None
if getattr(self.account.prefs, 'platform_type', '') == 'youtube':
self.templates_panel = TemplatesPanel(self.account, self.notebook)
self.notebook.AddPage(self.templates_panel, "Templates")
self.main_box.Add(self.notebook, 0, wx.ALL, 10)
self.ok = wx.Button(self.panel, wx.ID_OK, "&OK")
self.ok.SetDefault()
Expand All @@ -203,6 +208,12 @@ def OnOK(self, event):
# Save timeline order
self.account.prefs.timeline_order = self.timelines_panel.get_order()

# Save YouTube post template (and refresh display so it takes effect now)
if self.templates_panel is not None:
self.account.prefs.youtube_template = self.templates_panel.get_template()
for tl in getattr(self.account, 'timelines', []):
tl.invalidate_display_cache()

# Handle mentions_in_notifications setting change
if self.general.mentions_in_notifications is not None:
old_value = self.account.prefs.mentions_in_notifications
Expand Down Expand Up @@ -469,3 +480,52 @@ def on_remove(self, event):
new_idx = min(idx, self.alias_list.GetCount() - 1)
self.alias_list.SetSelection(new_idx)
self._update_buttons()


# Default YouTube post template: author name + handle + title + date.
DEFAULT_YOUTUBE_TEMPLATE = "$account.display_name$ (@$account.acct$): $text$ $created_at$"


class TemplatesPanel(wx.Panel):
"""Panel for customizing how YouTube posts (videos) are displayed."""

def __init__(self, account, parent):
super().__init__(parent)
self.account = account
self.main_box = wx.BoxSizer(wx.VERTICAL)

info = wx.StaticText(self, -1,
"How YouTube videos are shown in timelines. Edit the template below.")
self.main_box.Add(info, 0, wx.ALL, 10)

self.template_label = wx.StaticText(self, -1, "Post &template")
self.main_box.Add(self.template_label, 0, wx.LEFT | wx.TOP, 10)
current = getattr(account.prefs, 'youtube_template', '') or DEFAULT_YOUTUBE_TEMPLATE
self.template = wx.TextCtrl(self, -1, current, style=wx.TE_MULTILINE, name="Post template")
self.main_box.Add(self.template, 0, wx.EXPAND | wx.ALL, 10)

# Placeholder reference
placeholders = (
"Placeholders:\n"
"$text$ - video title\n"
"$account.display_name$ - channel name\n"
"$account.acct$ - channel @handle\n"
"$created_at$ - publish date\n"
"$description$ - full video description\n"
"$url$ - link to the video"
)
self.placeholders_label = wx.StaticText(self, -1, placeholders)
self.main_box.Add(self.placeholders_label, 0, wx.ALL, 10)

self.reset_btn = wx.Button(self, -1, "&Reset to Default")
self.reset_btn.Bind(wx.EVT_BUTTON, self.on_reset)
self.main_box.Add(self.reset_btn, 0, wx.ALL, 10)

self.SetSizer(self.main_box)

def on_reset(self, event):
self.template.SetValue(DEFAULT_YOUTUBE_TEMPLATE)

def get_template(self):
value = self.template.GetValue().strip()
return value or DEFAULT_YOUTUBE_TEMPLATE
15 changes: 14 additions & 1 deletion GUI/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def add_items(self):
instance = parsed.netloc or parsed.path.strip('/')
if instance:
acct = f"{acct} on {instance}"
elif platform_type == 'youtube':
acct = f"{acct} on YouTube"
self.list.Insert(acct, self.list.GetCount())
if i==app.currentAccount:
self.list.SetSelection(index)
Expand All @@ -92,7 +94,18 @@ def on_list_change(self,event):
def New(self, event):
app = get_app()
num_accounts_before = len(app.accounts)
app.add_session()
# Use the next CONTIGUOUS folder index, not the lowest free one. Startup
# loads accounts via range(prefs.accounts), so an account placed at a
# non-contiguous index (caused by a leftover/orphan folder on disk) is
# never loaded -> the account seems to "not save" and must be re-added
# every boot. Clear any orphan squatting at that index (it isn't a
# loaded account, so it's stale data) before creating the new one.
new_index = app.prefs.accounts
orphan = os.path.join(app.confpath, f"account{new_index}")
loaded_here = any(getattr(a, 'folder_index', -1) == new_index for a in app.accounts)
if os.path.exists(orphan) and not loaded_here:
shutil.rmtree(orphan, ignore_errors=True)
app.add_session(new_index)
# Check if a new account was actually added
if len(app.accounts) <= num_accounts_before:
# Account creation was cancelled or failed
Expand Down
63 changes: 63 additions & 0 deletions GUI/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,71 @@ def __init__(self, parent):
vlc_sizer.Add(self.vlc_download, 0)
self.main_box.Add(vlc_sizer, 0, wx.ALL, 10)

# YouTube account (Google OAuth) status + sign out
account_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.youtube_status = wx.StaticText(self, -1, self._youtube_status_text())
account_sizer.Add(self.youtube_status, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10)
self.youtube_signout = wx.Button(self, -1, "Sign out of YouTube")
self.youtube_signout.Bind(wx.EVT_BUTTON, self.on_youtube_signout)
self.youtube_signout.Enable(self._find_youtube_account() is not None)
account_sizer.Add(self.youtube_signout, 0)
self.main_box.Add(account_sizer, 0, wx.ALL, 10)

self.SetSizer(self.main_box)

def _find_youtube_account(self):
"""Return a signed-in YouTube account (current first), or None."""
app = get_app()
cur = getattr(app, 'currentAccount', None)
if cur is not None and getattr(cur.prefs, 'platform_type', '') == 'youtube':
return cur
for acc in getattr(app, 'accounts', []):
if getattr(acc.prefs, 'platform_type', '') == 'youtube':
return acc
return None

def _youtube_status_text(self):
"""Human-readable sign-in status for the YouTube account."""
acc = self._find_youtube_account()
if acc is None:
return "YouTube: no account signed in."
me = getattr(getattr(acc, '_platform', None), 'me', None)
name = getattr(me, 'display_name', '') or getattr(me, 'username', '')
if name:
return f"YouTube: signed in as {name}."
return "YouTube: signed in."

def on_youtube_signout(self, event):
"""Clear the stored OAuth token so the next launch re-prompts login."""
import speak
acc = self._find_youtube_account()
if acc is None:
speak.speak("No YouTube account is signed in.")
return
if wx.MessageBox(
"Sign out of YouTube? You'll need to log in again the next time you start FastSM.",
"Confirm Sign Out", wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
return
# Best-effort: revoke the grant at Google so the token can't be reused.
try:
from platforms.youtube import oauth
token = acc.prefs.get("youtube_token", None)
if token:
oauth.revoke_token(dict(token) if not isinstance(token, dict) else token)
except Exception:
pass
# Per-account prefs autosave, so this persists immediately.
acc.prefs.youtube_token = {}
# Drop in-memory creds so nothing refreshes/re-saves the token this session.
try:
if getattr(acc, '_platform', None) is not None:
acc._platform.credentials = None
except Exception:
pass
self.youtube_status.SetLabel("YouTube: signed out. Restart FastSM to log in again.")
self.youtube_signout.Enable(False)
speak.speak("Signed out of YouTube. Restart FastSM to log in again.")

def on_ytdlp_browse(self, event):
"""Browse for yt-dlp executable."""
with wx.FileDialog(self, "Select yt-dlp executable",
Expand Down
4 changes: 4 additions & 0 deletions GUI/platform_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ def __init__(self, parent):
# Radio buttons
self.mastodon_radio = wx.RadioButton(panel, label="Mastodon", style=wx.RB_GROUP)
self.bluesky_radio = wx.RadioButton(panel, label="Bluesky")
self.youtube_radio = wx.RadioButton(panel, label="YouTube")

radio_sizer = wx.BoxSizer(wx.VERTICAL)
radio_sizer.Add(self.mastodon_radio, 0, wx.ALL, 5)
radio_sizer.Add(self.bluesky_radio, 0, wx.ALL, 5)
radio_sizer.Add(self.youtube_radio, 0, wx.ALL, 5)
sizer.Add(radio_sizer, 0, wx.CENTER, 5)

# Buttons
Expand All @@ -46,6 +48,8 @@ def get_platform(self) -> str:
"""Return the selected platform name."""
if self.bluesky_radio.GetValue():
return "bluesky"
if self.youtube_radio.GetValue():
return "youtube"
return "mastodon"


Expand Down
81 changes: 74 additions & 7 deletions application.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,10 @@ def load(self):
parallelizable = []
sequential = []
for i in range(1, self.prefs.accounts):
if self._is_account_configured(i):
# YouTube accounts always load on the main thread: a dead/expired
# token triggers an interactive browser re-login, which must not
# run on a worker thread (it opens a browser + binds a local port).
if self._is_account_configured(i) and self._account_platform_type(i) != "youtube":
parallelizable.append(i)
else:
sequential.append(i)
Expand Down Expand Up @@ -314,6 +317,37 @@ def add_session(self, index=None):
return
# Otherwise just skip this account

@staticmethod
def _account_platform_type(index):
"""Read an account's platform_type from disk without loading it."""
import config
try:
if config.is_portable_mode():
prefs = config.Config(name="account"+str(index), autosave=False, save_on_exit=False)
else:
prefs = config.Config(name="FastSM/account"+str(index), autosave=False, save_on_exit=False)
return prefs.get("platform_type", "")
except:
return ""

@staticmethod
def _youtube_refresh_token(prefs):
"""Return the stored YouTube refresh token string, or "".

Coerces youtube_token (which may come back as a config.Config, a dict,
or something unexpected) to a dict so both account-state checks agree.
"""
import config
token = prefs.get("youtube_token", None)
if isinstance(token, config.Config):
try:
token = dict(token)
except Exception:
token = {}
elif not isinstance(token, dict):
token = {}
return token.get("refresh_token") or ""

def _is_account_configured(self, index):
"""Check if an account has credentials saved (no dialogs needed)."""
import config
Expand All @@ -329,6 +363,9 @@ def _is_account_configured(self, index):
if platform_type == "bluesky":
# Bluesky needs handle and password
return bool(prefs.get("bluesky_handle", "")) and bool(prefs.get("bluesky_password", ""))
elif platform_type == "youtube":
# YouTube needs a stored OAuth refresh token
return bool(self._youtube_refresh_token(prefs))
else:
# Mastodon needs instance URL and access token
return bool(prefs.get("instance_url", "")) and bool(prefs.get("access_token", ""))
Expand Down Expand Up @@ -358,6 +395,11 @@ def _is_account_partially_configured(self, index):
return (True, "bluesky", handle or "incomplete")
# Just platform_type set, nothing else
return (True, "bluesky", "setup not started")
elif platform_type == "youtube":
if self._youtube_refresh_token(prefs):
return (False, None, None) # Fully configured
# Platform chosen but OAuth login never finished
return (True, "youtube", "login not completed")
else:
# Mastodon
instance_url = prefs.get("instance_url", "")
Expand Down Expand Up @@ -549,7 +591,21 @@ def process_status(self, s, return_only_text=False, template="", ignore_cw=False
if is_scheduled:
return self._process_scheduled_status(s)

if hasattr(s, 'content'):
# YouTube posts use a per-account template (Templates tab in account
# options). Default shows title + author; the description is opt-in via
# the $description$ placeholder.
if template == "" and getattr(s, '_platform', '') == 'youtube':
acct_obj = account or getattr(self, 'currentAccount', None)
if acct_obj is not None:
yt_tpl = getattr(acct_obj.prefs, 'youtube_template', '') or ''
if yt_tpl:
template = yt_tpl

if getattr(s, '_platform', '') == 'youtube':
# The displayed post text is the video title (stored in .text);
# the full description lives in .content for $description$.
text = getattr(s, 'text', '') or (self.strip_html(s.content) if hasattr(s, 'content') else "")
elif hasattr(s, 'content'):
text = self.strip_html(s.content)
else:
text = ""
Expand Down Expand Up @@ -593,8 +649,10 @@ def process_status(self, s, return_only_text=False, template="", ignore_cw=False
text = f"{filter_warning}. {text}"
# 'ignore' mode: just use the text as-is

# Add media descriptions to text (only for non-reblogs to avoid duplication)
if self.prefs.include_media_descriptions and hasattr(s, 'media_attachments') and s.media_attachments:
# Add media descriptions to text (only for non-reblogs to avoid duplication).
# Skipped for YouTube: the video IS the post, so a "(Video) with no
# description" suffix is just noise.
if self.prefs.include_media_descriptions and getattr(s, '_platform', '') != 'youtube' and hasattr(s, 'media_attachments') and s.media_attachments:
for media in s.media_attachments:
# Handle both objects (from API) and dicts (from cache)
if isinstance(media, dict):
Expand Down Expand Up @@ -1021,6 +1079,12 @@ def template_to_string(self, s, template="", account=None):
if template == "":
template = self.prefs.postTemplate

# $description$ (YouTube): the full video description, kept in .content.
# Deferred like $text$ so $..$ inside it isn't treated as a template var.
description_content = None
if "$description$" in template:
description_content = self.strip_html(getattr(s, 'content', '') or '')

# Prepare text content now, but replace AFTER other substitutions
# to prevent $..$ patterns in post text from being interpreted as template vars
text_content = None
Expand Down Expand Up @@ -1064,8 +1128,8 @@ def template_to_string(self, s, template="", account=None):
if "$" in temp[i]:
t = temp[i].split("$")
r = t[1]
if r == "text":
continue # Already handled above
if r == "text" or r == "description":
continue # Handled (deferred) below
if "." in r:
q = r.split(".")
# Support multi-level attribute access (e.g., reblog.account.display_name)
Expand Down Expand Up @@ -1145,9 +1209,12 @@ def template_to_string(self, s, template="", account=None):
template = template.replace("$" + t[1] + "$", str(getattr(s, t[1])))
except Exception as e:
print(e)
# Replace $text$ last to prevent post content with $..$ from being interpreted as template vars
# Replace $text$ / $description$ last to prevent post content with $..$
# from being interpreted as template vars
if text_content is not None:
template = template.replace("$text$", text_content)
if description_content is not None:
template = template.replace("$description$", description_content)
return template

def get_users_in_status(self, account, s):
Expand Down
Loading