riplex 0.5.2__tar.gz → 0.5.4__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 (122) hide show
  1. {riplex-0.5.2/src/riplex.egg-info → riplex-0.5.4}/PKG-INFO +1 -1
  2. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/manifest.py +2 -1
  3. {riplex-0.5.2 → riplex-0.5.4/src/riplex.egg-info}/PKG-INFO +1 -1
  4. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/update.py +37 -25
  5. riplex-0.5.4/src/riplex_app/updater.py +122 -0
  6. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/lookup.py +3 -1
  7. {riplex-0.5.2 → riplex-0.5.4}/tests/test_updater.py +51 -4
  8. riplex-0.5.2/src/riplex_app/updater.py +0 -84
  9. {riplex-0.5.2 → riplex-0.5.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {riplex-0.5.2 → riplex-0.5.4}/.github/agents/riplex.agent.md +0 -0
  11. {riplex-0.5.2 → riplex-0.5.4}/.github/copilot-instructions.md +0 -0
  12. {riplex-0.5.2 → riplex-0.5.4}/.github/workflows/publish.yml +0 -0
  13. {riplex-0.5.2 → riplex-0.5.4}/.github/workflows/release.yml +0 -0
  14. {riplex-0.5.2 → riplex-0.5.4}/.gitignore +0 -0
  15. {riplex-0.5.2 → riplex-0.5.4}/.vscode/settings.json +0 -0
  16. {riplex-0.5.2 → riplex-0.5.4}/LICENSE +0 -0
  17. {riplex-0.5.2 → riplex-0.5.4}/README.md +0 -0
  18. {riplex-0.5.2 → riplex-0.5.4}/REFACTOR_PLAN.md +0 -0
  19. {riplex-0.5.2 → riplex-0.5.4}/docs/architecture.md +0 -0
  20. {riplex-0.5.2 → riplex-0.5.4}/docs/changelog.md +0 -0
  21. {riplex-0.5.2 → riplex-0.5.4}/docs/getting-started/configuration.md +0 -0
  22. {riplex-0.5.2 → riplex-0.5.4}/docs/getting-started/installation.md +0 -0
  23. {riplex-0.5.2 → riplex-0.5.4}/docs/guide/lookup.md +0 -0
  24. {riplex-0.5.2 → riplex-0.5.4}/docs/guide/orchestrate.md +0 -0
  25. {riplex-0.5.2 → riplex-0.5.4}/docs/guide/organize.md +0 -0
  26. {riplex-0.5.2 → riplex-0.5.4}/docs/guide/workflow.md +0 -0
  27. {riplex-0.5.2 → riplex-0.5.4}/docs/index.md +0 -0
  28. {riplex-0.5.2 → riplex-0.5.4}/docs/naming-rules.md +0 -0
  29. {riplex-0.5.2 → riplex-0.5.4}/docs/reference/cli.md +0 -0
  30. {riplex-0.5.2 → riplex-0.5.4}/docs/troubleshooting.md +0 -0
  31. {riplex-0.5.2 → riplex-0.5.4}/issues/cross-disc-dvdcompare-matching.md +0 -0
  32. {riplex-0.5.2 → riplex-0.5.4}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  33. {riplex-0.5.2 → riplex-0.5.4}/issues/planned-features.md +0 -0
  34. {riplex-0.5.2 → riplex-0.5.4}/mkdocs.yml +0 -0
  35. {riplex-0.5.2 → riplex-0.5.4}/pyproject.toml +0 -0
  36. {riplex-0.5.2 → riplex-0.5.4}/setup.cfg +0 -0
  37. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/__init__.py +0 -0
  38. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/cache.py +0 -0
  39. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/config.py +0 -0
  40. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/dedup.py +0 -0
  41. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/detect.py +0 -0
  42. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/disc/__init__.py +0 -0
  43. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/disc/analysis.py +0 -0
  44. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/disc/makemkv.py +0 -0
  45. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/disc/provider.py +0 -0
  46. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/formatter.py +0 -0
  47. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/lookup.py +0 -0
  48. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/matcher.py +0 -0
  49. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/metadata/__init__.py +0 -0
  50. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/metadata/planner.py +0 -0
  51. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/metadata/provider.py +0 -0
  52. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/metadata/sources/__init__.py +0 -0
  53. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/metadata/sources/tmdb.py +0 -0
  54. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/models.py +0 -0
  55. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/normalize.py +0 -0
  56. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/organizer.py +0 -0
  57. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/scanner.py +0 -0
  58. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/snapshot.py +0 -0
  59. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/splitter.py +0 -0
  60. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/tagger.py +0 -0
  61. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/title.py +0 -0
  62. {riplex-0.5.2 → riplex-0.5.4}/src/riplex/ui.py +0 -0
  63. {riplex-0.5.2 → riplex-0.5.4}/src/riplex.egg-info/SOURCES.txt +0 -0
  64. {riplex-0.5.2 → riplex-0.5.4}/src/riplex.egg-info/dependency_links.txt +0 -0
  65. {riplex-0.5.2 → riplex-0.5.4}/src/riplex.egg-info/entry_points.txt +0 -0
  66. {riplex-0.5.2 → riplex-0.5.4}/src/riplex.egg-info/requires.txt +0 -0
  67. {riplex-0.5.2 → riplex-0.5.4}/src/riplex.egg-info/top_level.txt +0 -0
  68. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/__init__.py +0 -0
  69. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/main.py +0 -0
  70. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/__init__.py +0 -0
  71. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/disc_detection.py +0 -0
  72. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/disc_overview.py +0 -0
  73. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/disc_swap.py +0 -0
  74. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/done.py +0 -0
  75. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/folder_picker.py +0 -0
  76. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/metadata.py +0 -0
  77. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/orchestrate_done.py +0 -0
  78. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/organize_done.py +0 -0
  79. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/organize_preview.py +0 -0
  80. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/progress.py +0 -0
  81. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/release.py +0 -0
  82. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/selection.py +0 -0
  83. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_app/screens/welcome.py +0 -0
  84. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/__init__.py +0 -0
  85. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/__init__.py +0 -0
  86. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/orchestrate.py +0 -0
  87. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/organize.py +0 -0
  88. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/rip.py +0 -0
  89. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/commands/setup.py +0 -0
  90. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/formatting.py +0 -0
  91. {riplex-0.5.2 → riplex-0.5.4}/src/riplex_cli/main.py +0 -0
  92. {riplex-0.5.2 → riplex-0.5.4}/tests/__init__.py +0 -0
  93. {riplex-0.5.2 → riplex-0.5.4}/tests/fixtures/chernobyl_disc1.json +0 -0
  94. {riplex-0.5.2 → riplex-0.5.4}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  95. {riplex-0.5.2 → riplex-0.5.4}/tests/fixtures/makemkvcon_list.txt +0 -0
  96. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  97. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  98. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  99. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  100. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  101. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  102. {riplex-0.5.2 → riplex-0.5.4}/tests/snapshots/Waterworld.snapshot.json +0 -0
  103. {riplex-0.5.2 → riplex-0.5.4}/tests/test_cache.py +0 -0
  104. {riplex-0.5.2 → riplex-0.5.4}/tests/test_cli_utils.py +0 -0
  105. {riplex-0.5.2 → riplex-0.5.4}/tests/test_config.py +0 -0
  106. {riplex-0.5.2 → riplex-0.5.4}/tests/test_dedup.py +0 -0
  107. {riplex-0.5.2 → riplex-0.5.4}/tests/test_detect.py +0 -0
  108. {riplex-0.5.2 → riplex-0.5.4}/tests/test_disc_analysis.py +0 -0
  109. {riplex-0.5.2 → riplex-0.5.4}/tests/test_disc_fixtures.py +0 -0
  110. {riplex-0.5.2 → riplex-0.5.4}/tests/test_disc_provider.py +0 -0
  111. {riplex-0.5.2 → riplex-0.5.4}/tests/test_formatter.py +0 -0
  112. {riplex-0.5.2 → riplex-0.5.4}/tests/test_makemkv.py +0 -0
  113. {riplex-0.5.2 → riplex-0.5.4}/tests/test_matcher.py +0 -0
  114. {riplex-0.5.2 → riplex-0.5.4}/tests/test_normalize.py +0 -0
  115. {riplex-0.5.2 → riplex-0.5.4}/tests/test_organizer.py +0 -0
  116. {riplex-0.5.2 → riplex-0.5.4}/tests/test_planner.py +0 -0
  117. {riplex-0.5.2 → riplex-0.5.4}/tests/test_rip_guide.py +0 -0
  118. {riplex-0.5.2 → riplex-0.5.4}/tests/test_scanner.py +0 -0
  119. {riplex-0.5.2 → riplex-0.5.4}/tests/test_snapshot.py +0 -0
  120. {riplex-0.5.2 → riplex-0.5.4}/tests/test_splitter.py +0 -0
  121. {riplex-0.5.2 → riplex-0.5.4}/tests/test_tagger.py +0 -0
  122. {riplex-0.5.2 → riplex-0.5.4}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -33,8 +33,9 @@ def build_rip_path(
33
33
  is not configured.
34
34
  """
35
35
  from riplex.config import get_output_root, get_rip_output
36
+ from riplex.normalize import sanitize_filename
36
37
 
37
- folder_base = f"{canonical} ({year})"
38
+ folder_base = sanitize_filename(f"{canonical} ({year})")
38
39
  rip_output = get_rip_output()
39
40
  if rip_output:
40
41
  rip_root = Path(rip_output) / folder_base
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -23,31 +23,32 @@ class UpdateScreen:
23
23
  ])
24
24
 
25
25
  tag = update_info["tag"]
26
- body = update_info.get("body", "").strip()
26
+ releases = update_info.get("releases", [])
27
27
  release_url = update_info.get("url", "")
28
28
  current = get_current_version()
29
29
 
30
- # Clean up sparse release notes (auto-generated GitHub changelog links)
31
- if body:
32
- # De-duplicate repeated lines
33
- seen = []
34
- for line in body.splitlines():
35
- if line not in seen:
36
- seen.append(line)
37
- body = "\n".join(seen)
30
+ log.info("Update screen: %s -> %s (%d releases in series)", current, tag, len(releases))
38
31
 
39
- # If it's just changelog links with no real content, replace with a summary
40
- non_empty = [l for l in seen if l.strip()]
41
- if all("full changelog" in l.lower() or not l.strip() for l in seen):
42
- body = ""
32
+ # Build release notes content
33
+ notes_controls = []
34
+ for i, rel in enumerate(releases):
35
+ rel_body = self._clean_body(rel.get("body", ""))
36
+ if not rel_body:
37
+ rel_body = "*No release notes available.*"
43
38
 
44
- if not body:
45
- body = (
46
- f"A new version of riplex ({tag}) is available.\n\n"
47
- "Visit the release page for full details and download links."
39
+ notes_controls.append(
40
+ ft.Text(rel["tag"], size=16, weight=ft.FontWeight.BOLD),
48
41
  )
49
-
50
- log.info("Update screen: %s -> %s", current, tag)
42
+ notes_controls.append(
43
+ ft.Markdown(
44
+ rel_body,
45
+ selectable=True,
46
+ extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
47
+ on_tap_link=lambda e: webbrowser.open(e.data),
48
+ ),
49
+ )
50
+ if i < len(releases) - 1:
51
+ notes_controls.append(ft.Divider(height=10, color=ft.Colors.GREY_800))
51
52
 
52
53
  return ft.Column(
53
54
  [
@@ -75,12 +76,7 @@ class UpdateScreen:
75
76
  ft.Text("Release Notes", size=18, weight=ft.FontWeight.BOLD),
76
77
  ft.Container(height=4),
77
78
  ft.Container(
78
- ft.Markdown(
79
- body if body else "*No release notes available.*",
80
- selectable=True,
81
- extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
82
- on_tap_link=lambda e: webbrowser.open(e.data),
83
- ),
79
+ ft.Column(notes_controls, spacing=8),
84
80
  expand=True,
85
81
  padding=ft.padding.all(10),
86
82
  border=ft.border.all(1, ft.Colors.GREY_800),
@@ -113,3 +109,19 @@ class UpdateScreen:
113
109
 
114
110
  def _go_back(self, e):
115
111
  self.app.navigate("welcome")
112
+
113
+ @staticmethod
114
+ def _clean_body(body: str) -> str:
115
+ """Clean up sparse or auto-generated release notes."""
116
+ body = body.strip()
117
+ if not body:
118
+ return ""
119
+ # De-duplicate repeated lines
120
+ seen = []
121
+ for line in body.splitlines():
122
+ if line not in seen:
123
+ seen.append(line)
124
+ # If it's just changelog links with no real content, discard
125
+ if all("full changelog" in l.lower() or not l.strip() for l in seen):
126
+ return ""
127
+ return "\n".join(seen)
@@ -0,0 +1,122 @@
1
+ """Check for newer releases on GitHub."""
2
+
3
+ import urllib.request
4
+ import json
5
+ import sys
6
+ from importlib.metadata import version, PackageNotFoundError
7
+
8
+ GITHUB_REPO = "AnyCredit5518/riplex"
9
+ RELEASES_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases"
10
+ LATEST_RELEASE_URL = f"{RELEASES_URL}/latest"
11
+
12
+
13
+ def get_current_version() -> str:
14
+ """Return the installed package version, or 'dev' if not installed."""
15
+ try:
16
+ return version("riplex")
17
+ except PackageNotFoundError:
18
+ return "dev"
19
+
20
+
21
+ def _parse_version(tag: str) -> tuple:
22
+ """Parse 'v1.2.3' into (1, 2, 3) for comparison."""
23
+ tag = tag.lstrip("v")
24
+ parts = []
25
+ for p in tag.split("."):
26
+ try:
27
+ parts.append(int(p))
28
+ except ValueError:
29
+ break
30
+ return tuple(parts)
31
+
32
+
33
+ def _major_minor(version_tuple: tuple) -> tuple:
34
+ """Return (major, minor) from a parsed version tuple."""
35
+ return version_tuple[:2] if len(version_tuple) >= 2 else version_tuple
36
+
37
+
38
+ def check_for_update() -> dict | None:
39
+ """Check GitHub for a newer release.
40
+
41
+ Returns a dict with 'tag', 'url', 'releases', and 'assets' if an update
42
+ is available, or None if already up to date (or on error).
43
+
44
+ 'releases' is a list of dicts (tag, url, body) for all releases in the
45
+ latest minor version series, ordered newest first. For example if the
46
+ latest release is v0.5.2, releases will contain v0.5.2, v0.5.1, v0.5.0.
47
+ """
48
+ current = get_current_version()
49
+ if current == "dev":
50
+ return None
51
+
52
+ try:
53
+ req = urllib.request.Request(
54
+ RELEASES_URL + "?per_page=30",
55
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "riplex"},
56
+ )
57
+ with urllib.request.urlopen(req, timeout=8) as resp:
58
+ all_releases = json.loads(resp.read())
59
+ except Exception:
60
+ return None
61
+
62
+ if not all_releases:
63
+ return None
64
+
65
+ # Sort by parsed version descending
66
+ tagged = []
67
+ for r in all_releases:
68
+ tag = r.get("tag_name", "")
69
+ if not tag:
70
+ continue
71
+ parsed = _parse_version(tag)
72
+ if parsed:
73
+ tagged.append((parsed, r))
74
+ tagged.sort(key=lambda x: x[0], reverse=True)
75
+
76
+ if not tagged:
77
+ return None
78
+
79
+ latest_parsed, latest_release = tagged[0]
80
+ current_parsed = _parse_version(current)
81
+
82
+ if latest_parsed <= current_parsed:
83
+ return None
84
+
85
+ # Collect all releases in the same major.minor series
86
+ latest_minor = _major_minor(latest_parsed)
87
+ series = []
88
+ for parsed, r in tagged:
89
+ if _major_minor(parsed) == latest_minor:
90
+ series.append({
91
+ "tag": r["tag_name"],
92
+ "url": r.get("html_url", ""),
93
+ "body": r.get("body", ""),
94
+ })
95
+
96
+ # Assets from the latest release
97
+ assets = {}
98
+ for asset in latest_release.get("assets", []):
99
+ name = asset["name"].lower()
100
+ assets[name] = asset["browser_download_url"]
101
+
102
+ return {
103
+ "tag": latest_release["tag_name"],
104
+ "url": latest_release.get("html_url", ""),
105
+ "body": latest_release.get("body", ""),
106
+ "releases": series,
107
+ "assets": assets,
108
+ }
109
+
110
+
111
+ def get_download_url(update_info: dict) -> str:
112
+ """Get the platform-appropriate download URL from update info."""
113
+ if sys.platform == "win32":
114
+ for name, url in update_info["assets"].items():
115
+ if "ui" in name and "windows" in name:
116
+ return url
117
+ elif sys.platform == "darwin":
118
+ for name, url in update_info["assets"].items():
119
+ if "ui" in name and "macos" in name:
120
+ return url
121
+ # Fallback to release page
122
+ return update_info["url"]
@@ -323,7 +323,9 @@ async def run_lookup(args: argparse.Namespace) -> int:
323
323
  print("Error: --output or output_root config required for --create-folders.", file=sys.stderr)
324
324
  return 1
325
325
  rip_output = get_rip_output()
326
- makemkv_root = Path(rip_output) / f"{canonical} ({year})" if rip_output else Path(output_val) / "Rips" / f"{canonical} ({year})"
326
+ from riplex.normalize import sanitize_filename
327
+ folder_base = sanitize_filename(f"{canonical} ({year})")
328
+ makemkv_root = Path(rip_output) / folder_base if rip_output else Path(output_val) / "Rips" / folder_base
327
329
  created = create_rip_folders(makemkv_root, discs)
328
330
  if created:
329
331
  print(f"\nCreated {len(created)} folder(s) under {makemkv_root}")
@@ -51,11 +51,11 @@ class TestCheckForUpdate:
51
51
  assert check_for_update() is None
52
52
 
53
53
  def test_returns_none_when_up_to_date(self):
54
- response_data = json.dumps({
54
+ response_data = json.dumps([{
55
55
  "tag_name": "v0.2.3",
56
56
  "html_url": "https://github.com/AnyCredit5518/riplex/releases/tag/v0.2.3",
57
57
  "assets": [],
58
- }).encode()
58
+ }]).encode()
59
59
 
60
60
  mock_resp = MagicMock()
61
61
  mock_resp.read.return_value = response_data
@@ -67,14 +67,15 @@ class TestCheckForUpdate:
67
67
  assert check_for_update() is None
68
68
 
69
69
  def test_returns_update_info_when_newer(self):
70
- response_data = json.dumps({
70
+ response_data = json.dumps([{
71
71
  "tag_name": "v0.3.0",
72
72
  "html_url": "https://github.com/AnyCredit5518/riplex/releases/tag/v0.3.0",
73
+ "body": "### Added\n- Cool feature",
73
74
  "assets": [
74
75
  {"name": "riplex-ui-windows.exe", "browser_download_url": "https://example.com/win.exe"},
75
76
  {"name": "riplex-ui-macos.zip", "browser_download_url": "https://example.com/mac.zip"},
76
77
  ],
77
- }).encode()
78
+ }]).encode()
78
79
 
79
80
  mock_resp = MagicMock()
80
81
  mock_resp.read.return_value = response_data
@@ -89,6 +90,52 @@ class TestCheckForUpdate:
89
90
  assert result["tag"] == "v0.3.0"
90
91
  assert "riplex-ui-windows.exe" in result["assets"]
91
92
  assert "riplex-ui-macos.zip" in result["assets"]
93
+ assert len(result["releases"]) == 1
94
+ assert result["releases"][0]["tag"] == "v0.3.0"
95
+
96
+ def test_groups_releases_by_minor_version(self):
97
+ response_data = json.dumps([
98
+ {"tag_name": "v0.5.2", "html_url": "url/v0.5.2", "body": "Fix 2", "assets": []},
99
+ {"tag_name": "v0.5.1", "html_url": "url/v0.5.1", "body": "Fix 1", "assets": []},
100
+ {"tag_name": "v0.5.0", "html_url": "url/v0.5.0", "body": "Big release", "assets": []},
101
+ {"tag_name": "v0.4.0", "html_url": "url/v0.4.0", "body": "Old", "assets": []},
102
+ ]).encode()
103
+
104
+ mock_resp = MagicMock()
105
+ mock_resp.read.return_value = response_data
106
+ mock_resp.__enter__ = lambda s: s
107
+ mock_resp.__exit__ = MagicMock(return_value=False)
108
+
109
+ with patch("riplex_app.updater.get_current_version", return_value="0.4.0"):
110
+ with patch("urllib.request.urlopen", return_value=mock_resp):
111
+ result = check_for_update()
112
+
113
+ assert result is not None
114
+ assert result["tag"] == "v0.5.2"
115
+ assert len(result["releases"]) == 3
116
+ assert [r["tag"] for r in result["releases"]] == ["v0.5.2", "v0.5.1", "v0.5.0"]
117
+
118
+ def test_does_not_mix_major_versions(self):
119
+ response_data = json.dumps([
120
+ {"tag_name": "v1.0.1", "html_url": "url/v1.0.1", "body": "Patch", "assets": []},
121
+ {"tag_name": "v1.0.0", "html_url": "url/v1.0.0", "body": "Major", "assets": []},
122
+ {"tag_name": "v0.5.0", "html_url": "url/v0.5.0", "body": "Old minor", "assets": []},
123
+ ]).encode()
124
+
125
+ mock_resp = MagicMock()
126
+ mock_resp.read.return_value = response_data
127
+ mock_resp.__enter__ = lambda s: s
128
+ mock_resp.__exit__ = MagicMock(return_value=False)
129
+
130
+ with patch("riplex_app.updater.get_current_version", return_value="0.5.0"):
131
+ with patch("urllib.request.urlopen", return_value=mock_resp):
132
+ result = check_for_update()
133
+
134
+ assert result is not None
135
+ assert result["tag"] == "v1.0.1"
136
+ # Should only contain v1.0.x, not v0.5.0
137
+ assert len(result["releases"]) == 2
138
+ assert [r["tag"] for r in result["releases"]] == ["v1.0.1", "v1.0.0"]
92
139
 
93
140
 
94
141
  # ---------------------------------------------------------------------------
@@ -1,84 +0,0 @@
1
- """Check for newer releases on GitHub."""
2
-
3
- import urllib.request
4
- import json
5
- import sys
6
- from importlib.metadata import version, PackageNotFoundError
7
-
8
- GITHUB_REPO = "AnyCredit5518/riplex"
9
- RELEASES_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
10
-
11
-
12
- def get_current_version() -> str:
13
- """Return the installed package version, or 'dev' if not installed."""
14
- try:
15
- return version("riplex")
16
- except PackageNotFoundError:
17
- return "dev"
18
-
19
-
20
- def _parse_version(tag: str) -> tuple:
21
- """Parse 'v1.2.3' into (1, 2, 3) for comparison."""
22
- tag = tag.lstrip("v")
23
- parts = []
24
- for p in tag.split("."):
25
- try:
26
- parts.append(int(p))
27
- except ValueError:
28
- break
29
- return tuple(parts)
30
-
31
-
32
- def check_for_update() -> dict | None:
33
- """Check GitHub for a newer release.
34
-
35
- Returns a dict with 'tag', 'url', and 'assets' if an update is available,
36
- or None if already up to date (or on error).
37
- """
38
- current = get_current_version()
39
- if current == "dev":
40
- return None
41
-
42
- try:
43
- req = urllib.request.Request(
44
- RELEASES_URL,
45
- headers={"Accept": "application/vnd.github+json", "User-Agent": "riplex"},
46
- )
47
- with urllib.request.urlopen(req, timeout=5) as resp:
48
- data = json.loads(resp.read())
49
- except Exception:
50
- return None
51
-
52
- latest_tag = data.get("tag_name", "")
53
- if not latest_tag:
54
- return None
55
-
56
- if _parse_version(latest_tag) > _parse_version(current):
57
- # Find the right asset for this platform
58
- assets = {}
59
- for asset in data.get("assets", []):
60
- name = asset["name"].lower()
61
- assets[name] = asset["browser_download_url"]
62
-
63
- return {
64
- "tag": latest_tag,
65
- "url": data.get("html_url", ""),
66
- "body": data.get("body", ""),
67
- "assets": assets,
68
- }
69
-
70
- return None
71
-
72
-
73
- def get_download_url(update_info: dict) -> str:
74
- """Get the platform-appropriate download URL from update info."""
75
- if sys.platform == "win32":
76
- for name, url in update_info["assets"].items():
77
- if "ui" in name and "windows" in name:
78
- return url
79
- elif sys.platform == "darwin":
80
- for name, url in update_info["assets"].items():
81
- if "ui" in name and "macos" in name:
82
- return url
83
- # Fallback to release page
84
- return update_info["url"]
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
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
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
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes