youpdated 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {youpdated-0.3.0 → youpdated-0.3.1}/CHANGELOG.md +34 -0
  2. {youpdated-0.3.0 → youpdated-0.3.1}/PKG-INFO +1 -1
  3. {youpdated-0.3.0 → youpdated-0.3.1}/pyproject.toml +1 -1
  4. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/__init__.py +1 -1
  5. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/http.py +20 -1
  6. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/runner.py +21 -7
  7. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/browser.py +4 -2
  8. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/feed.py +5 -3
  9. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/itch.py +18 -6
  10. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/youtube.py +4 -2
  11. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/state.py +43 -1
  12. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/PKG-INFO +1 -1
  13. {youpdated-0.3.0 → youpdated-0.3.1}/ADDING_SOURCES.md +0 -0
  14. {youpdated-0.3.0 → youpdated-0.3.1}/CONTRIBUTING.md +0 -0
  15. {youpdated-0.3.0 → youpdated-0.3.1}/LICENSE +0 -0
  16. {youpdated-0.3.0 → youpdated-0.3.1}/MANIFEST.in +0 -0
  17. {youpdated-0.3.0 → youpdated-0.3.1}/README.md +0 -0
  18. {youpdated-0.3.0 → youpdated-0.3.1}/SECURITY.md +0 -0
  19. {youpdated-0.3.0 → youpdated-0.3.1}/setup.cfg +0 -0
  20. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/__main__.py +0 -0
  21. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/cleanup.py +0 -0
  22. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/cli.py +0 -0
  23. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/config.py +0 -0
  24. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/crypto.py +0 -0
  25. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/models.py +0 -0
  26. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/py.typed +0 -0
  27. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/registry.py +0 -0
  28. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/__init__.py +0 -0
  29. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/json_out.py +0 -0
  30. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/rss_out.py +0 -0
  31. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/render/terminal.py +0 -0
  32. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/__init__.py +0 -0
  33. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/base.py +0 -0
  34. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/generic.py +0 -0
  35. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/github.py +0 -0
  36. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/npm.py +0 -0
  37. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated/sources/steam.py +0 -0
  38. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/SOURCES.txt +0 -0
  39. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/dependency_links.txt +0 -0
  40. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/entry_points.txt +0 -0
  41. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/requires.txt +0 -0
  42. {youpdated-0.3.0 → youpdated-0.3.1}/youpdated.egg-info/top_level.txt +0 -0
@@ -4,6 +4,39 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.1] — 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - **itch builds no longer re-report on unchanged pages.** itch.io shuffles HTML
12
+ attribute order, and the upload list was only read in one
13
+ order, so some fetches saw no files and fingerprinted a "new build". Successive
14
+ runs each reported a different handful of games until every variant had been
15
+ seen. Upload and timestamp parsing no longer depend on attribute order.
16
+ - **YouTube Shorts no longer re-report when the feed falls back.** The official
17
+ feed links Shorts as `/shorts/<id>`, which was not recognised, so Shorts were
18
+ recorded as `yt:video:yt:video:<id>` there and `yt:video:<id>` through Invidious
19
+ or Data API. Each switch reported Shorts again. Existing
20
+ history is migrated on first open, so nothing already seen comes back.
21
+ - **`--no-save` really leaves the state alone.** It still stored each feed's
22
+ ETag/Last-Modified, so the next real run was answered 304 and silently skipped
23
+ whatever the preview had shown. (Whoops)
24
+ - **A target that fails partway no longer hides its new items.** If one of a
25
+ target's fetches failed (e.g. GitHub `watch: [releases, commits]` with commits
26
+ down), the others' validators were still stored while their items were
27
+ discarded, so the next run got a 304 and never reported them. Validators are
28
+ now stored only alongside the items they cover.
29
+ - **Feeds listed oldest-first report new entries.** Entries were cut to the
30
+ limit in document order before sorting, so on an oldest-first feed longer than
31
+ the limit the newest posts never made it in. Affects `feed`, Steam, itch
32
+ devlogs, and GitHub atom feeds.
33
+ - **Chrome no longer errors** when a version is first listed without a rollout
34
+ start time and later with.
35
+ - **Removing an `ignore:` rule shows what it hid.** Ignored items
36
+ are not recorded, but the feed's validators were, so hidden items stayed hidden until it next changed.
37
+ Each target's rules are now recorded, and a target whose rules changed is fetched fully once.
38
+ Every target is fetched in full once after upgrading.
39
+
7
40
  ## [0.3.0] — 2026-10-07
8
41
 
9
42
  ### Added
@@ -237,6 +270,7 @@ First release.
237
270
  - Firefox publishes current versions, so it reports one item per channel.
238
271
  - Edge exposes release notes only for the stable and beta channels. (But like, it's Edge, why do you want to know when it updates?)
239
272
 
273
+ [0.3.1]: https://github.com/Void1-1/youpdated/releases/tag/v0.3.1
240
274
  [0.3.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.3.0
241
275
  [0.2.1]: https://github.com/Void1-1/youpdated/releases/tag/v0.2.1
242
276
  [0.2.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: youpdated
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Simple update tracker for games, apps, and packages
5
5
  Author-email: Void1-1 <161782542+Void1-1@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "youpdated"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Simple update tracker for games, apps, and packages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Youpdated, a simple update tracker."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -29,6 +29,9 @@ except ImportError: # pragma: no cover
29
29
 
30
30
  NETWORK_ERRORS = (httpx.HTTPError, SOCKSError)
31
31
 
32
+ #: ``(url, etag, last_modified)`` from a 200, see :meth:`Client.deferred_validators`
33
+ Validator = tuple[str, str | None, str | None]
34
+
32
35
  # Small pool of current desktop UAs. Rotating inside a plausible set
33
36
  # it blends in better, but can be changed here
34
37
  USER_AGENTS: tuple[str, ...] = (
@@ -220,6 +223,17 @@ class Client:
220
223
  finally:
221
224
  self._local.unconditional = previous
222
225
 
226
+ @contextmanager
227
+ def deferred_validators(self) -> "Iterator[list[Validator]]":
228
+ """Collect validators this thread receives instead of storing"""
229
+ previous = getattr(self._local, "pending", None)
230
+ pending: list[Validator] = []
231
+ self._local.pending = pending
232
+ try:
233
+ yield pending
234
+ finally:
235
+ self._local.pending = previous
236
+
223
237
  # internals
224
238
 
225
239
  def _user_agent(self) -> str:
@@ -339,11 +353,16 @@ class Client:
339
353
  if self._run_active:
340
354
  self._run_bodies[cache_key] = result
341
355
  if conditional and self.state is not None:
342
- self.state.remember_validators(
356
+ validator = (
343
357
  url,
344
358
  response.headers.get("etag"),
345
359
  response.headers.get("last-modified"),
346
360
  )
361
+ pending = getattr(self._local, "pending", None)
362
+ if pending is not None:
363
+ pending.append(validator)
364
+ else:
365
+ self.state.remember_validators(*validator)
347
366
  return result
348
367
 
349
368
  if response.status_code in soft:
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
9
9
  from typing import Any, Protocol, runtime_checkable
10
10
 
11
11
  from .config import Config, ConfigError, parse_duration, parse_ignore_list
12
- from .http import Client
12
+ from .http import Client, Validator
13
13
  from .models import RunError, Target, Update
14
14
  from .registry import all_sources
15
15
  from .state import State
@@ -118,16 +118,26 @@ def run(
118
118
  sources = all_sources()
119
119
  fetched: list[Update] = []
120
120
  succeeded: dict[tuple[str, str], Target] = {}
121
+ # Stored only for targets whose items are recorded, see Client.deferred_validators
122
+ validators: list[Validator] = []
121
123
 
122
- def work(target: Target) -> tuple[Target, list[Update] | Exception, int]:
124
+ def work(
125
+ target: Target,
126
+ ) -> tuple[Target, list[Update] | Exception, int, list[Validator]]:
123
127
  if progress is not None:
124
128
  progress.target_started(target)
125
129
  outcome: list[Update] | Exception
126
130
  dropped = 0
127
- # A first fetch must see the whole document
128
- first = not state.is_baselined(target.source, target.key)
131
+ # A first fetch must see the whole document, same with one thats ignore rules changed
132
+ full = (
133
+ not state.is_baselined(target.source, target.key)
134
+ or state.ignore_rules_changed(target.source, target.key, target.ignore)
135
+ )
129
136
  try:
130
- with client.unconditional() if first else nullcontext():
137
+ with (
138
+ client.deferred_validators() as pending,
139
+ client.unconditional() if full else nullcontext(),
140
+ ):
131
141
  items = list(sources[target.source].fetch(target, client))
132
142
  kept = [u for u in items if not target.ignores(u.tags)]
133
143
  # Ignored items are not recorded as seen
@@ -139,7 +149,7 @@ def run(
139
149
  progress.target_finished(
140
150
  target, outcome if isinstance(outcome, Exception) else None
141
151
  )
142
- return target, outcome, dropped
152
+ return target, outcome, dropped, pending
143
153
 
144
154
  if progress is not None:
145
155
  progress.run_started(len(targets))
@@ -147,7 +157,7 @@ def run(
147
157
  workers = max(1, min(config.privacy.concurrency, len(targets)))
148
158
  # run_scope: targets that share an upstream document fetch it once between them
149
159
  with client.run_scope(), ThreadPoolExecutor(max_workers=workers) as pool:
150
- for target, outcome, dropped in pool.map(work, targets):
160
+ for target, outcome, dropped, pending in pool.map(work, targets):
151
161
  result.ignored += dropped
152
162
  if isinstance(outcome, Exception):
153
163
  result.errors.append(
@@ -159,6 +169,7 @@ def run(
159
169
  )
160
170
  else:
161
171
  fetched.extend(outcome)
172
+ validators.extend(pending)
162
173
  succeeded.setdefault((target.source, target.key), target)
163
174
 
164
175
  result.total_fetched = len(fetched)
@@ -181,7 +192,10 @@ def run(
181
192
 
182
193
  if save:
183
194
  state.mark_seen(fetched)
195
+ for validator in validators:
196
+ state.remember_validators(*validator)
184
197
  state.mark_baselined(succeeded)
198
+ state.mark_ignore_rules((s, k, t.ignore) for (s, k), t in succeeded.items())
185
199
  state.set_last_run()
186
200
  # Prune only after marking. Targets not fetched cleanly (failed, or left out by --source) keep history
187
201
  result.pruned = state.prune(
@@ -164,13 +164,15 @@ class BrowserSource:
164
164
  return []
165
165
 
166
166
  # One version can appear several times as its rollout fraction grows: keeps the earliest start time per version.
167
- starts: dict[str, datetime] = {}
167
+ starts: dict[str, datetime | None] = {}
168
168
  for release in fetched.json().get("releases", []):
169
169
  version = release.get("version")
170
170
  started = _parse_iso((release.get("serving") or {}).get("startTime"))
171
171
  if not version:
172
172
  continue
173
- if version not in starts or (started and started < starts[version]):
173
+ if version not in starts:
174
+ starts[version] = started
175
+ elif started and (starts[version] is None or started < starts[version]):
174
176
  starts[version] = started
175
177
 
176
178
  ordered = sorted(
@@ -101,8 +101,12 @@ def parse_entries(
101
101
  ) -> list[Update]:
102
102
  """Turn an already-parsed feed into Updates, newest first."""
103
103
  updates: list[Update] = []
104
+ # Sort before truncating: some feeds list oldest first. Undated entries sort last
105
+ entries = sorted(
106
+ parsed.entries, key=lambda e: entry_datetime(e) or _UNDATED, reverse=True
107
+ )
104
108
 
105
- for entry in parsed.entries[: limit or None]:
109
+ for entry in entries[: limit or None]:
106
110
  link = entry.get("link") or ""
107
111
  title = (entry.get("title") or "").strip() or "(untitled)"
108
112
  uid = entry.get("id") or link or f"{title}|{entry.get('published', '')}"
@@ -120,8 +124,6 @@ def parse_entries(
120
124
  )
121
125
  )
122
126
 
123
- # Undated entries sort last
124
- updates.sort(key=lambda u: u.published or _UNDATED, reverse=True)
125
127
  return updates
126
128
 
127
129
 
@@ -27,8 +27,11 @@ GAME_URL_RE = re.compile(
27
27
 
28
28
  # info panel: <tr><td>Updated</td><td><abbr title="17 August 2026 @ 10:34 UTC">…
29
29
  INFO_ROW_RE = re.compile(r"<td>([^<]{1,30})</td><td>(.{0,400}?)</td>", re.S)
30
- ABBR_TITLE_RE = re.compile(r'<abbr title="([^"]+)"')
31
- UPLOAD_NAME_RE = re.compile(r'<strong title="([^"]*)" class="name">')
30
+ # itch shuffles attribute order between responses, so never assume any
31
+ ABBR_TITLE_RE = re.compile(r'<abbr\b[^>]*?\stitle="([^"]+)"')
32
+ STRONG_TAG_RE = re.compile(r"<strong\b([^>]*)>")
33
+ NAME_CLASS_RE = re.compile(r'(?:^|\s)class="name"')
34
+ TITLE_ATTR_RE = re.compile(r'(?:^|\s)title="([^"]*)"')
32
35
  UPLOAD_SIZE_RE = re.compile(r'class="file_size"><span>([^<]*)</span>')
33
36
  ITCH_DATE_RE = re.compile(
34
37
  r"(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s*@\s*(\d{1,2}):(\d{2}))?", re.I
@@ -188,10 +191,19 @@ class ItchSource:
188
191
  return rows
189
192
 
190
193
  def _uploads(self, html: str) -> list[tuple[str, str]]:
191
- names = UPLOAD_NAME_RE.findall(html)
192
- sizes = UPLOAD_SIZE_RE.findall(html)
193
- # Sizes can be missing for some entries
194
- return [(n, sizes[i] if i < len(sizes) else "") for i, n in enumerate(names)]
194
+ names: list[tuple[str, int]] = []
195
+ for tag in STRONG_TAG_RE.finditer(html):
196
+ attrs = tag.group(1)
197
+ title = TITLE_ATTR_RE.search(attrs)
198
+ if title and NAME_CLASS_RE.search(attrs):
199
+ names.append((title.group(1), tag.end()))
200
+ uploads = []
201
+ for i, (name, start) in enumerate(names):
202
+ # Sizes can be missing for some entries; only look up to the next upload
203
+ end = names[i + 1][1] if i + 1 < len(names) else len(html)
204
+ size = UPLOAD_SIZE_RE.search(html, start, end)
205
+ uploads.append((name, size.group(1) if size else ""))
206
+ return uploads
195
207
 
196
208
 
197
209
  def _parse_itch_date(value: str | None) -> datetime | None:
@@ -263,7 +263,8 @@ class YouTubeSource:
263
263
  # Rewrite uids to the video id so the three paths dedupe
264
264
  normalized = []
265
265
  for update in updates:
266
- vid = _video_id(update.url) or update.uid
266
+ # Entry ids are already ``yt:video:<id>``
267
+ vid = _video_id(update.url) or update.uid.removeprefix("yt:video:")
267
268
  normalized.append(
268
269
  Update(
269
270
  source=update.source,
@@ -326,7 +327,8 @@ def _extract_playlist_id(raw: str) -> str:
326
327
 
327
328
 
328
329
  def _video_id(url: str) -> str | None:
329
- match = re.search(r"[?&]v=([A-Za-z0-9_-]{11})", url or "")
330
+ # Shorts are linked as /shorts/<id> rather than watch?v=<id>
331
+ match = re.search(r"(?:[?&]v=|/shorts/)([A-Za-z0-9_-]{11})", url or "")
330
332
  return match.group(1) if match else None
331
333
 
332
334
 
@@ -45,6 +45,10 @@ def _ago(age: timedelta) -> str:
45
45
  return (datetime.now(timezone.utc) - age).isoformat()
46
46
 
47
47
 
48
+ def _rules_value(ignore: frozenset[str]) -> str:
49
+ return ",".join(sorted(ignore))
50
+
51
+
48
52
  class State:
49
53
  """Thread-safe wrapper over SQLite file."""
50
54
 
@@ -90,6 +94,17 @@ class State:
90
94
  self._conn.execute("ALTER TABLE seen ADD COLUMN last_seen TEXT")
91
95
  self._conn.execute("UPDATE seen SET last_seen = first_seen")
92
96
  self._dirty = True
97
+ # <= 0.3.0 stored YouTube Shorts from the official feed as yt:video:yt:video:<id>
98
+ fixed = self._conn.execute(
99
+ "UPDATE OR IGNORE seen SET uid = substr(uid, 10) "
100
+ "WHERE source = 'youtube' AND uid LIKE 'yt:video:yt:video:%'"
101
+ ).rowcount
102
+ # Rows left over already had the correct id recorded by a fallback path
103
+ fixed += self._conn.execute(
104
+ "DELETE FROM seen WHERE source = 'youtube' AND uid LIKE 'yt:video:yt:video:%'"
105
+ ).rowcount
106
+ if fixed:
107
+ self._dirty = True
93
108
 
94
109
  def _read_encrypted(self) -> bytes | None:
95
110
  """Decrypt the state file into a database image, or None if there is no file yet."""
@@ -247,8 +262,13 @@ class State:
247
262
  "DELETE FROM kv WHERE namespace='baseline' AND key=?",
248
263
  [(key,) for key in stale_marks],
249
264
  )
265
+ # Rules are recorded with the baseline mark, and go with it
266
+ removed_rules = self._conn.execute(
267
+ "DELETE FROM kv WHERE namespace='ignore' AND key NOT IN "
268
+ "(SELECT key FROM kv WHERE namespace='baseline')"
269
+ ).rowcount
250
270
  self._conn.commit()
251
- if removed or removed_validators or stale_marks:
271
+ if removed or removed_validators or stale_marks or removed_rules:
252
272
  # reclaim empty so the file shrinks
253
273
  self._conn.execute("VACUUM")
254
274
  self._dirty = True
@@ -301,6 +321,28 @@ class State:
301
321
  self._conn.commit()
302
322
  self._dirty = True
303
323
 
324
+ # per target ignore rules
325
+
326
+ def ignore_rules_changed(self, source: str, target: str, ignore: frozenset[str]) -> bool:
327
+ """If ``ignore`` is different from the rules the target's last saved fetch ran under"""
328
+ return self.cache_get("ignore", f"{source}:{target}") != _rules_value(ignore)
329
+
330
+ def mark_ignore_rules(self, targets: Iterable[tuple[str, str, frozenset[str]]]) -> None:
331
+ rows = [
332
+ ("ignore", f"{source}:{target}", _rules_value(ignore))
333
+ for source, target, ignore in targets
334
+ ]
335
+ if not rows:
336
+ return
337
+ with self._lock:
338
+ self._conn.executemany(
339
+ "INSERT INTO kv (namespace, key, value) VALUES (?,?,?) "
340
+ "ON CONFLICT(namespace, key) DO UPDATE SET value=excluded.value",
341
+ rows,
342
+ )
343
+ self._conn.commit()
344
+ self._dirty = True
345
+
304
346
  # upkeeping
305
347
 
306
348
  def last_run(self) -> datetime | None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: youpdated
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Simple update tracker for games, apps, and packages
5
5
  Author-email: Void1-1 <161782542+Void1-1@users.noreply.github.com>
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes