diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fa82b61..c3e1f8bf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,8 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
+ - name: Release notes composer
+ run: python3 tool/compose_release_notes_test.py
- uses: subosito/flutter-action@v2
with:
channel: stable
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5d026761..89625ad4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1027,8 +1027,21 @@ jobs:
V: ${{ needs.version.outputs.name }}
BUILD: ${{ needs.version.outputs.build }}
NOTES: ${{ inputs.notes }}
+ SHA: ${{ needs.version.outputs.sha }}
+ TAG: ${{ needs.version.outputs.tag }}
run: |
set -euo pipefail
+ # Hand-written notes and screenshots, when this version has them.
+ status=0
+ python3 tool/compose_release_notes.py --version "${V}" --sha "${SHA}" \
+ --repo "${GITHUB_REPOSITORY}" --tag "${TAG}" --assets rel \
+ --out RELEASE_NOTES.md || status=$?
+ if [ "${status}" -eq 0 ]; then
+ echo "── notes ──"; cat RELEASE_NOTES.md
+ exit 0
+ elif [ "${status}" -ne 3 ]; then
+ exit "${status}"
+ fi
{
if [ -n "${NOTES}" ]; then
printf '%s\n\n' "${NOTES}"
diff --git a/docs/releases/v3.2.1.md b/docs/releases/v3.2.1.md
new file mode 100644
index 00000000..8ae7c3e7
--- /dev/null
+++ b/docs/releases/v3.2.1.md
@@ -0,0 +1,143 @@
+
+ Sozo v3.2.1 is a patch release that fixes what you reported after v3.2.0.
+ Videos no longer load forever, dropped connections recover, and more downloads work.
+ It also adds a corner player on desktop, Vietnamese and Hindi, and sorting in My List.
+
+
+
+
+
+
+
+
+
+---
+
+## ▶️ Playback that recovers
+
+| | |
+|---|---|
+| ⏱️ **No more endless "Video loading"** | A server now has about 25 seconds to start (about 35 for streams found inside a web page, about 90 for torrents). After that, the player moves to the next server. On Android, if the main player hands a stream to the system player, that player gets its own time. If the servers it tries all fail, it tells you. |
+| 🔌 **Dropped connections** | After "Connection reset by peer", the player reconnects and carries on from the same position. A file cut short by the network now reconnects too, instead of stopping silently. You see an error only if that fails. |
+| 🪜 **More servers get tried** | Many sources played the first server and then showed an error, with the others untried. They now try other servers, and your remembered quality is used. |
+| 🎯 **1080p means 1080p** | On Android's system player, a quality you pick stays locked instead of starting at the lowest one. Auto now shows plain "Auto" instead of the size it started at. |
+| 🎞️ **Video the player can't decode** | On Android, Sozo now switches to the system player when its main player can't open a video's codec. A single bad audio or video packet no longer stops playback with an error. |
+| 🌑 **Black picture** | On Android, a video no longer stays black until your first seek. If the main player can't show a picture, Sozo switches to the system player. |
+| 🖼️ **Seek previews** | Frames should now arrive on slow hosts, a frame still loading when you lift your finger should be kept for the next drag, and a dead host should no longer leave a skeleton forever. The Android changes have not been tested on a device yet. The card now reads `00:49 / 02:07`. |
+
+
+
+---
+
+## 🖥️ Corner player on desktop
+
+
+
+On Windows, macOS and Linux, press **I** or the mini player button to dock the video in a corner of the app. It keeps playing while you move around Home, Search, detail pages and settings. The video does not reload.
+
+- **Move and resize it.** Drag it and it snaps to the nearest corner. Drag an inner edge to resize it. Sozo remembers the corner and the width.
+- **Hover controls.** Close, back to the full player, previous and next episode, play and pause, 10-second steps when there is room, and a timeline.
+- **The keyboard stays with your page.** Click the corner player to give it the keyboard. Esc or a click elsewhere gives it back without closing the video.
+- **Above other apps.** Press **Shift+I** or long-press the button for a small always-on-top window. If you switch to another app while a video is playing, Sozo now shrinks to this window by itself. On macOS it also appears on every Space and beside full-screen apps.
+
+---
+
+## ⬇️ Downloads
+
+> **If a download failed with "The source sent a web page instead of the video", delete it and download it again on v3.2.1.** A failed download keeps its old address, so retrying it may not help.
+
+- **Fewer false "web page" errors.** Some hosts label real video as a web page. Sozo now checks the first bytes of the file, and refuses only real web pages.
+- **Downloads ask for a file the way the player does.** Some hosts refused downloads of an episode that played fine.
+- **One bad mirror no longer fails the episode.** It fails only if every mirror serves a page. Otherwise, the bad ones are left out of the choices.
+- **Android: Save to Downloads now writes the video of an HLS download.** It used to save only a small playlist file. The parts are now joined into one `.ts` or `.mp4` file. Downloads with a separate audio track or SAMPLE-AES encryption are refused with a reason.
+
+---
+
+## ✨ Added
+
+
+
+- 🌍 **Vietnamese and Hindi**, so Sozo now has 16 languages. Subtitles in these languages are found by their labels and offered as translation targets. Both translations are machine-written and no native speaker has read them yet, so corrections are welcome.
+- 📋 **Sort My List** by recently added, oldest added or title. Sozo remembers your choice on the device.
+- 🔀 **Auto in "Try another source".** Sozo goes through the matches it trusts and opens the first one that can play the title.
+- 📺 **One season at a time.** Sources that list every season together, such as CloudStream plugins, now get season chips, and episodes show as `S2 E1`. Seasons no longer sort into each other.
+- 🎬 **Relations for films and series.** A title that isn't anime now shows the rest of its film series and TMDB's recommendations. AniList results appear only when the title really matches.
+- 🔎 **Pinch zoom in the continuous manga reader.** Before, only the paged mode could zoom.
+- 🚫 **Spliced-in ads.** A source can now mark ad segments in a stream, and the player leaves them out.
+- 💬 **Burned-in subtitles.** A source can now say which language is already in the picture, and auto-translate won't draw a second line over it.
+- 🍎 **iPhone player.** Clear glass under the controls and the system spinner when a video stalls. On all phones, locking or unlocking gives a firm tap and a double-tap seek gives a tick.
+
+
+
+
+🔧 Other fixes
+
+- **Windows:** turning on Anime4K no longer gives a black screen with a playback error.
+- **Arabic:** tapping a tab in the bottom bar opened the mirror-image tab. It now opens the one you tapped.
+- **Sources with no video for a title.** When a source moved a title to its own app or has no player, Sozo says so in your language. After that first try, Play is replaced by a way to find the title on another source. If a series was opened as a film, Sozo opens its episode list when the source gave one.
+- **Fairer "Down" badges.** A source that runs on your phone no longer shows as Down only because our server timed out or couldn't reach its site. Long-press its row to check from your device. An empty catalogue now counts as a problem.
+- **"Hide down sources"** now shows the number it really hides, and the quick-switch sheet uses the same health rules as the Sources screen.
+- **Source check:** the message and the Hide button had swapped text in every language.
+- **CloudStream:** search now works for plugins that only have paged search, such as MovieBox.
+- **Streams stored inside image files** now play through the built-in proxy, and the proxy answers range requests the way players expect.
+- **Subtitles:** you now get a message when a translation stops part way.
+- **Subtitles:** a track in your subtitle language now starts ahead of the source's default, and one-tap translate uses a human subtitle in that language when there is one.
+- **Long Vietnamese titles** keep their tone marks, and Hindi gets the same extra line height.
+- **My List:** a catalogue title shows its name, not `cat:anilist`.
+- **Support:** a follow-up on a ticket that had diagnostics now sends a newer report.
+- **Corner player:** signing out or switching profile closes it first, so your last history save lands in the right account.
+
+
+
+---
+
+## ⬇ Download
+
+
+
+
+🍎 macOS says the app is "damaged" or won't open
+
+The DMG isn't signed with an Apple certificate. Run this once in Terminal:
+
+```
+xattr -dr com.apple.quarantine /Applications/Sozo.app
+```
+
+Or in Applications, right-click Sozo.app → **Open** → **Open**.
+
+
+---
+
+## 💬 Join the community
+
+
+
+
+
+
+
+
+
+- 📣 **Telegram**: [@sozoapp](https://telegram.me/sozoapp) for new versions and announcements first.
+- 🎧 **Discord**: [discord.gg/n22URhYvMR](https://discord.gg/n22URhYvMR) to suggest ideas and report bugs.
+- 🛟 **In the app**: Profile → **Help & Support**. Many of these fixes started as tickets there.
+- 🐛 **On GitHub**: [a source isn't working](https://github.com/professorDeveloper/sozo/issues/new?template=source_not_working.yml) · [bug report](https://github.com/professorDeveloper/sozo/issues/new?template=bug_report.yml) · [feature request](https://github.com/professorDeveloper/sozo/issues/new?template=feature_request.yml)
+- ⭐ **If Sozo is useful to you, [star the repo](https://github.com/professorDeveloper/sozo)**.
+
+
+🇺🇿 O'zbekcha qisqacha
+
+**Sozo v3.2.1: v3.2.0 dan keyin bildirilgan muammolar tuzatildi.**
+
+- ▶️ **Pleer.** Endi "Video loading" cheksiz turmaydi: server taxminan 25 soniyada ochilmasa (torrent uchun taxminan 90 soniya), pleer keyingi serverga o'tadi. Aloqa uzilsa, video o'sha joyidan davom etadi.
+- ⬇️ **Yuklab olish.** Noto'g'ri "web page" xatolari tuzatildi. **Avval xato bergan yuklamani o'chirib, v3.2.1 da qaytadan yuklab oling.** Android'da *Save to Downloads* endi HLS yuklamaning o'zini video fayl qilib saqlaydi.
+- 🖥️ **Desktop.** Yangi burchak pleeri: **I** tugmasini bosing, video ilova burchagida davom etadi, siz esa boshqa sahifalarga o'tasiz. **Shift+I** boshqa ilovalar ustida turadigan kichik oynani ochadi.
+- 🌍 **Vyetnam va hind tillari** qo'shildi, jami 16 ta til. Bu ikki tarjima mashinada qilingan.
+- ✨ **Yangi.** My List'ni saralash, *Try another source* ichida **Auto**, CloudStream seriallari uchun fasllar bo'yicha ko'rinish, film va seriallar uchun Relations, manga reader'ning uzluksiz rejimida pinch zoom.
+- 🔧 **Tuzatildi.** Windows'da Anime4K qora ekrani, arab tilida pastki panel, "Down" belgilari va boshqalar.
+
+Yangiliklar: [@sozoapp](https://telegram.me/sozoapp) · Fikr va xatolar: [Discord](https://discord.gg/n22URhYvMR)
+
+
+Full list of changes: v3.2.0...v3.2.1
diff --git a/tool/compose_release_notes.py b/tool/compose_release_notes.py
new file mode 100644
index 00000000..5cf77203
--- /dev/null
+++ b/tool/compose_release_notes.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Builds a release body from docs/releases/v.md.
+
+Images may be written relative to the notes file. They become links to the
+exact commit being released, so they work in a draft before anything reaches
+the default branch. An image that is not in the repository is removed and
+reported, so a release never shows a broken picture.
+
+ becomes a table of the files actually attached.
+
+Exit codes: 0 written, 3 no notes file for this version (the workflow then falls
+back to its generated notes).
+"""
+import argparse
+import posixpath
+import re
+import sys
+from pathlib import Path
+
+# Longest suffix first: "tv-arm64-v8a.apk" also ends in "arm64-v8a.apk".
+PLATFORMS = (
+ ("tv-arm64-v8a.apk", "📺", "Android TV"),
+ ("tv-armeabi-v7a.apk", "📺", "Android TV (older 32-bit devices)"),
+ ("arm64-v8a.apk", "🟢", "Android (most phones)"),
+ ("armeabi-v7a.apk", "🟡", "Android (older 32-bit phones)"),
+ ("universal.apk", "🔵", "Android (if you're not sure; larger, works everywhere)"),
+ ("x86_64.apk", "⚪", "Android (x86_64 and emulators)"),
+ ("Windows-Setup.exe", "🪟", "Windows"),
+ ("Windows-portable.zip", "🪟", "Windows (no install)"),
+ ("macOS.dmg", "🍎", "macOS 12+"),
+ ("Linux-x64.tar.gz", "🐧", "Linux x64"),
+ ("iOS.ipa", "📱", "iOS 15+ (install with AltStore, SideStore or Sideloadly)"),
+)
+
+IMAGE_BLOCK = re.compile(r']*>\s*(
]*>)\s*
\n?', re.I)
+HTML_IMG = re.compile(r'
]*?\bsrc="([^"]+)"[^>]*>', re.I)
+MD_IMG = re.compile(r'!\[[^\]]*\]\(([^)\s]+)\)')
+
+
+def is_relative(src):
+ return not re.match(r'^(?:[a-z][a-z0-9+.-]*:|//|#|/)', src, re.I)
+
+
+def rewrite_images(text, notes_path, repo_root, repo, sha, warn):
+ base = f"https://raw.githubusercontent.com/{repo}/{sha}/"
+ notes_dir = notes_path.parent.relative_to(repo_root).as_posix()
+
+ def target(src):
+ path = posixpath.normpath(posixpath.join(notes_dir, src))
+ if path.startswith("../") or not (repo_root / path).is_file():
+ warn(f"image not found, removed: {src}")
+ return None
+ return base + path
+
+ def block(match):
+ src = HTML_IMG.search(match.group(1))
+ if src and is_relative(src.group(1)) and target(src.group(1)) is None:
+ return ""
+ return match.group(0)
+
+ def html(match):
+ src = match.group(1)
+ if not is_relative(src):
+ return match.group(0)
+ url = target(src)
+ return match.group(0).replace(src, url, 1) if url else ""
+
+ def markdown(match):
+ src = match.group(1)
+ if not is_relative(src):
+ return match.group(0)
+ url = target(src)
+ return match.group(0).replace(src, url, 1) if url else ""
+
+ text = IMAGE_BLOCK.sub(block, text)
+ # A missing image in a block was already removed and warned about once.
+ return MD_IMG.sub(markdown, HTML_IMG.sub(html, text))
+
+
+def downloads_table(assets, repo, tag, version):
+ names = sorted(p.name for p in assets.iterdir()) if assets.is_dir() else []
+ rows = []
+ for suffix, icon, label in PLATFORMS:
+ name = f"Sozo-v{version}-{suffix}"
+ if name in names:
+ url = f"https://github.com/{repo}/releases/download/{tag}/{name}"
+ rows.append(f"| {icon} {label} | [`{name}`]({url}) |")
+ if not rows:
+ return "The files are attached to this release."
+ lines = ["| Platform | File |", "|---|---|", *rows]
+ sums = f"Sozo-v{version}-SHA256SUMS.txt"
+ if sums in names:
+ url = f"https://github.com/{repo}/releases/download/{tag}/{sums}"
+ lines += ["", f"🔐 To check a download, compare it with [`{sums}`]({url})."]
+ return "\n".join(lines)
+
+
+def compose(version, sha, repo, tag, assets, repo_root, warn):
+ notes = repo_root / "docs" / "releases" / f"v{version}.md"
+ if not notes.is_file():
+ return None
+ text = notes.read_text(encoding="utf-8")
+ text = rewrite_images(text, notes, repo_root, repo, sha, warn)
+ return text.replace("", downloads_table(assets, repo, tag, version))
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ for name in ("version", "sha", "repo", "tag", "assets", "out"):
+ parser.add_argument(f"--{name}", required=True)
+ parser.add_argument("--root", default=".")
+ args = parser.parse_args()
+ warn = lambda message: print(f"::warning::{message}")
+ body = compose(args.version, args.sha, args.repo, args.tag, Path(args.assets),
+ Path(args.root).resolve(), warn)
+ if body is None:
+ print(f"No docs/releases/v{args.version}.md")
+ return 3
+ Path(args.out).write_text(body, encoding="utf-8")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tool/compose_release_notes_test.py b/tool/compose_release_notes_test.py
new file mode 100644
index 00000000..beb8d362
--- /dev/null
+++ b/tool/compose_release_notes_test.py
@@ -0,0 +1,71 @@
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent))
+from compose_release_notes import compose
+
+SHA = "abc123"
+REPO = "o/r"
+RAW = f"https://raw.githubusercontent.com/{REPO}/{SHA}/docs/images/v1/"
+
+
+class ComposeTest(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = Path(tmp.name)
+ (self.root / "docs/releases").mkdir(parents=True)
+ (self.root / "docs/images/v1").mkdir(parents=True)
+ (self.root / "docs/images/v1/a.png").write_bytes(b"x")
+ self.assets = self.root / "rel"
+ self.assets.mkdir()
+ self.warnings = []
+
+ def run_it(self, text):
+ (self.root / "docs/releases/v1.md").write_text(text, encoding="utf-8")
+ return compose("1", SHA, REPO, "v1", self.assets, self.root, self.warnings.append)
+
+ def test_no_notes_file(self):
+ self.assertIsNone(compose("2", SHA, REPO, "v2", self.assets, self.root, self.warnings.append))
+
+ def test_relative_images_point_at_the_commit(self):
+ out = self.run_it('
\n\n')
+ self.assertEqual(out.count(RAW + "a.png"), 2)
+ self.assertIn('alt="A" width="9"', out)
+ self.assertEqual(self.warnings, [])
+
+ def test_missing_image_block_is_removed_and_reported(self):
+ out = self.run_it('Before\n\n
\n
\nAfter\n')
+ self.assertEqual(out, "Before\nAfter\n")
+ self.assertEqual(len(self.warnings), 1)
+
+ def test_missing_bare_image_is_removed(self):
+ self.assertEqual(self.run_it("A  B"), "A B")
+ self.assertEqual(len(self.warnings), 1)
+
+ def test_image_outside_the_repo_is_refused(self):
+ self.assertEqual(self.run_it(""), "")
+
+ def test_absolute_images_are_untouched(self):
+ text = '
'
+ self.assertEqual(self.run_it(text), text)
+ self.assertEqual(self.warnings, [])
+
+ def test_downloads_list_only_attached_files(self):
+ for name in ("arm64-v8a.apk", "tv-arm64-v8a.apk", "SHA256SUMS.txt"):
+ (self.assets / f"Sozo-v1-{name}").write_bytes(b"x")
+ out = self.run_it("")
+ self.assertIn("releases/download/v1/Sozo-v1-arm64-v8a.apk", out)
+ self.assertIn("Android TV", out)
+ self.assertIn("SHA256SUMS", out)
+ self.assertNotIn("Windows", out)
+ self.assertLess(out.index("Android TV"), out.index("most phones"))
+
+ def test_downloads_without_files(self):
+ self.assertIn("attached", self.run_it(""))
+
+
+if __name__ == "__main__":
+ unittest.main()