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. +

+ +

+ Download + Telegram + Discord + Star Sozo +

+ +--- + +## ▶️ 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`. | + +

The player showing the Switching server notice

+ +--- + +## 🖥️ Corner player on desktop + +

The corner player floating over a Sozo page 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 + +

The language picker with Vietnamese and Hindi

+ +- 🌍 **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. + +

My List with the sort menu open

+ +
+🔧 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 channel + Discord + Website + Developer on Telegram + Buy me a coffee +

+ +- 📣 **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('A\n![b](../images/v1/a.png)\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 ![x](no.png) B"), "A B") + self.assertEqual(len(self.warnings), 1) + + def test_image_outside_the_repo_is_refused(self): + self.assertEqual(self.run_it("![x](../../../../etc/passwd)"), "") + + def test_absolute_images_are_untouched(self): + text = ' ![b](https://x.test/b.png)' + 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()