From 8afe36b89b7c2c823acd30ee240d902203a9ad3b Mon Sep 17 00:00:00 2001 From: lssb Date: Sat, 19 Sep 2026 02:29:15 +0800 Subject: [PATCH 1/4] Keep .strm identity in shared playlists and autoload sidecar subs. mpv resolves .strm files to inner URLs; this build keeps the .strm name for Syncplay rooms for 25s after open, and starts mpv with a bundled lua script that loads matching subtitles next to the .strm file. Co-authored-by: Cursor --- buildPy2exe.py | 1 + syncplay/__init__.py | 6 +- syncplay/client.py | 19 +++-- syncplay/constants.py | 1 + syncplay/players/iina.py | 3 + syncplay/players/memento.py | 2 + syncplay/players/mpv.py | 9 +++ syncplay/resources/strm-sidecars.lua | 98 ++++++++++++++++++++++++ syncplay/strm.py | 89 ++++++++++++++++++++++ syncplay/ui/gui.py | 18 ++++- tests/__init__.py | 0 tests/test_strm_identity.py | 107 +++++++++++++++++++++++++++ 12 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 syncplay/resources/strm-sidecars.lua create mode 100644 syncplay/strm.py create mode 100644 tests/__init__.py create mode 100644 tests/test_strm_identity.py diff --git a/buildPy2exe.py b/buildPy2exe.py index 048fcd8ca..7cbda3713 100755 --- a/buildPy2exe.py +++ b/buildPy2exe.py @@ -718,6 +718,7 @@ def run(self): resources = [ "syncplay/resources/syncplayintf.lua", + "syncplay/resources/strm-sidecars.lua", "syncplay/resources/license.rtf", "syncplay/resources/third-party-notices.txt" ] diff --git a/syncplay/__init__.py b/syncplay/__init__.py index 4f27f610f..4188e4a45 100755 --- a/syncplay/__init__.py +++ b/syncplay/__init__.py @@ -1,5 +1,9 @@ version = '1.7.7' -revision = ' development' +revision = ' lssb-strm' milestone = 'Yoitsu' release_number = '120' projectURL = 'https://syncplay.pl/' +custom_build_note = ( + "本版本包含 .strm 播放列表身份与同名字幕补丁,由 lssb、白小九日 修改。
" + "This build adds .strm playlist identity and sidecar subtitle loading. Modified by lssb and 白小九日." +) diff --git a/syncplay/client.py b/syncplay/client.py index cae8de1ce..56673f2e7 100755 --- a/syncplay/client.py +++ b/syncplay/client.py @@ -38,6 +38,7 @@ pass from syncplay import utils, constants, version +from syncplay.strm import StrmIdentityTracker from syncplay.constants import PRIVACY_SENDHASHED_MODE, PRIVACY_DONTSEND_MODE, \ PRIVACY_HIDDENFILENAME from syncplay.messages import getMissingStrings, getMessage, isNoOSDMessage @@ -101,6 +102,7 @@ def __init__(self, playerClass, ui, config): self.lastRewindTime = None self.lastUpdatedFileTime = None self.lastAdvanceTime = None + self.strmIdentity = StrmIdentityTracker() self.fileOpenBeforeChangingPlaylistIndex = None self.waitingToLoadNewfile = False self.waitingToLoadNewfileSince = None @@ -562,18 +564,20 @@ def eofReportedByPlayer(self): def updateFile(self, filename, duration, path): self.lastUpdatedFileTime = time.time() newPath = "" - if utils.isURL(path): - filename = path if not path: return - try: - size = os.path.getsize(path) - except: + filename, path, strmSize = self.strmIdentity.canonicalize(filename, path) + if strmSize is not None: + size = strmSize + else: try: - path = path.decode('utf-8') size = os.path.getsize(path) except: - size = 0 + try: + path = path.decode('utf-8') + size = os.path.getsize(path) + except: + size = 0 if not utils.isURL(path) and os.path.exists(path): self.fileSwitch.notifyUserIfFileNotInMediaDirectory(filename, path) filename, size = self.__executePrivacySettings(filename, size) @@ -656,6 +660,7 @@ def openFile(self, filePath, resetPosition=False, fromUser=False): self.playlist.loadPlaylistFromFile(filePath, resetPosition) return + self.strmIdentity.begin_open(filePath) self.playlist.openedFile() self._player.openFile(filePath, resetPosition) if resetPosition: diff --git a/syncplay/constants.py b/syncplay/constants.py index 57d5c091a..1ef0eb18e 100755 --- a/syncplay/constants.py +++ b/syncplay/constants.py @@ -238,6 +238,7 @@ def getValueForOS(constantDict): MPV_SENDMESSAGE_COOLDOWN_TIME = 0.05 MPV_MAX_NEWFILE_COOLDOWN_TIME = 3 STREAM_ADDITIONAL_IGNORE_TIME = 10 +STRM_RESOLVE_TIMEOUT = 25 MPV_LOCK_WAIT_TIME = 0.05 VLC_OPEN_MAX_WAIT_TIME = 20 VLC_MIN_PORT = 10000 diff --git a/syncplay/players/iina.py b/syncplay/players/iina.py index 12ee2c3fe..17fd6f910 100644 --- a/syncplay/players/iina.py +++ b/syncplay/players/iina.py @@ -78,6 +78,9 @@ def _preparePlayer(self): for key, value in constants.IINA_PROPERTIES.items(): self._setProperty(key, value) self._listener.sendLine(["load-script", findResourcePath("syncplayintf.lua")]) + sidecar = MpvPlayer.strmSidecarScriptPath() + if sidecar: + self._listener.sendLine(["load-script", sidecar]) super()._preparePlayer() def _onFileUpdate(self): diff --git a/syncplay/players/memento.py b/syncplay/players/memento.py index 5df859eb5..c1fc0b80c 100644 --- a/syncplay/players/memento.py +++ b/syncplay/players/memento.py @@ -4,6 +4,7 @@ from syncplay.players.mpv import MpvPlayer from syncplay.utils import findResourcePath, playerPathExists +from syncplay.strm import add_strm_sidecar_script class MementoPlayer(MpvPlayer): @@ -52,6 +53,7 @@ def getIconPath(path): def getStartupArgs(userArgs): args = constants.MPV_ARGS args["scripts"] = findResourcePath("syncplayintf.lua") + add_strm_sidecar_script(args) if userArgs: for argToAdd in userArgs: if argToAdd.startswith("--"): diff --git a/syncplay/players/mpv.py b/syncplay/players/mpv.py index 214a68e8a..1b2a8b517 100755 --- a/syncplay/players/mpv.py +++ b/syncplay/players/mpv.py @@ -14,6 +14,7 @@ from syncplay.utils import getRuntimeDir, isURL, findResourcePath from syncplay.utils import isMacOS, isWindows, isASCII from syncplay.utils import playerPathExists +from syncplay.strm import add_strm_sidecar_script from syncplay.vendor.python_mpv_jsonipc.python_mpv_jsonipc import MPV class MpvPlayer(BasePlayer): @@ -53,10 +54,18 @@ def run(client, playerPath, filePath, args): "Please use mpv >=0.28.0.") return MpvPlayer(client, MpvPlayer.getExpandedPath(playerPath), filePath, args) + @staticmethod + def strmSidecarScriptPath(): + sidecar = findResourcePath("strm-sidecars.lua") + if os.path.isfile(sidecar): + return sidecar + return None + @staticmethod def getStartupArgs(userArgs): args = constants.MPV_ARGS args["script"] = findResourcePath("syncplayintf.lua") + add_strm_sidecar_script(args) if userArgs: for argToAdd in userArgs: if argToAdd.startswith('--'): diff --git a/syncplay/resources/strm-sidecars.lua b/syncplay/resources/strm-sidecars.lua new file mode 100644 index 000000000..e480509dc --- /dev/null +++ b/syncplay/resources/strm-sidecars.lua @@ -0,0 +1,98 @@ +-- Load sidecar subtitles from the directory of a .strm playlist file. +-- mpv treats .strm as a playlist, so path/filename become the inner URL and +-- default sub-auto looks next to that URL instead of next to the .strm. +-- Syncplay starts mpv with --scripts-append pointing at this file. + +local utils = require("mp.utils") +local msg = require("mp.msg") + +local SUB_EXTS = { + ass = true, srt = true, ssa = true, sub = true, vtt = true, + idx = true, lrc = true, smi = true, sup = true, +} + +local last_playlist_path = nil + +local function basename(path) + if not path then + return nil + end + return path:match("[^/\\]+$") +end + +local function dirname(path) + if not path then + return nil + end + return path:match("^(.*)[/\\]") +end + +local function stem(name) + if not name then + return nil + end + return name:gsub("%.[^/.]+$", "") +end + +local function extension(name) + if not name then + return nil + end + return name:lower():match("%.([^.]+)$") +end + +local function is_matching_sidecar(strm_stem, filename) + local ext = extension(filename) + if not ext or not SUB_EXTS[ext] then + return false + end + local file_stem = stem(filename) + if file_stem == strm_stem then + return true + end + local prefix = strm_stem .. "." + return file_stem:sub(1, #prefix) == prefix +end + +local function load_sidecars(playlist_path) + if not playlist_path or playlist_path == last_playlist_path then + return + end + if not playlist_path:lower():match("%.strm$") then + last_playlist_path = playlist_path + return + end + last_playlist_path = playlist_path + + local dir = dirname(playlist_path) + local strm_stem = stem(basename(playlist_path)) + if not dir or not strm_stem then + return + end + + local files = utils.readdir(dir, "files") + if not files then + msg.warn("strm-sidecars: cannot read " .. dir) + return + end + + local loaded = 0 + for _, filename in ipairs(files) do + if is_matching_sidecar(strm_stem, filename) then + local full = utils.join_path(dir, filename) + mp.commandv("sub-add", full, "auto") + loaded = loaded + 1 + msg.info("strm-sidecars: added " .. full) + end + end + if loaded == 0 then + msg.verbose("strm-sidecars: no sidecars next to " .. playlist_path) + end +end + +mp.observe_property("playlist-path", "string", function(_, value) + load_sidecars(value) +end) +mp.register_event("start-file", function() + load_sidecars(mp.get_property("playlist-path")) +end) diff --git a/syncplay/strm.py b/syncplay/strm.py new file mode 100644 index 000000000..97d920d65 --- /dev/null +++ b/syncplay/strm.py @@ -0,0 +1,89 @@ +import os +from urllib.parse import urlparse + +from syncplay import constants +from syncplay import utils + + +def is_strm_path(file_path): + if not file_path: + return False + path = str(file_path) + if "://" in path: + path = urlparse(path).path + return os.path.splitext(path)[1].lower() == ".strm" + + +def _canonical_filename(path): + if utils.isURL(path): + return path + return os.path.basename(path) + + +def add_strm_sidecar_script(args): + from syncplay.utils import findResourcePath + sidecar = findResourcePath("strm-sidecars.lua") + if os.path.isfile(sidecar): + args["scripts_append"] = sidecar + return args + + +class StrmIdentityTracker: + def __init__(self, timeout=None, time_fn=None): + self.timeout = constants.STRM_RESOLVE_TIMEOUT if timeout is None else timeout + self._time = time_fn + self.source = None + self.resolved_path = None + self.source_time = None + + def _now(self): + if self._time is None: + import time + return time.time() + return self._time() + + def clear(self): + self.source = None + self.resolved_path = None + self.source_time = None + + def begin_open(self, file_path): + self.clear() + if is_strm_path(file_path): + self.source = file_path + self.source_time = self._now() + + def _resolution_timed_out(self): + if self.source_time is None: + return True + return self._now() - self.source_time > self.timeout + + def canonicalize(self, filename, path): + if not path: + return filename, path, None + + if is_strm_path(path): + self.source = path + self.resolved_path = None + self.source_time = self._now() + return _canonical_filename(path), path, 0 + + if self.source: + if self.resolved_path is None: + if self._resolution_timed_out(): + self.clear() + elif utils.isURL(path): + self.resolved_path = path + path = self.source + return _canonical_filename(path), path, 0 + else: + self.clear() + elif path == self.resolved_path: + path = self.source + return _canonical_filename(path), path, 0 + else: + self.clear() + + if utils.isURL(path): + filename = path + return filename, path, None diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index 7bf86f342..9b295d5f3 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -12,7 +12,7 @@ from twisted.internet import task -from syncplay import utils, constants, version, revision, release_number +from syncplay import utils, constants, version, revision, release_number, custom_build_note from syncplay.messages import getMessage from syncplay.ui.consoleUI import ConsoleUI from syncplay.utils import resourcespath @@ -163,6 +163,11 @@ def __init__(self, parent=None): "

" + getMessage("about-dialog-release").format(versionExtString, release_number) + "
Python " + python_version() + " - " + __binding__ + " " + __binding_version__ + " - Qt " + __qt_version__ + "

") + noteLabel = None + if custom_build_note: + noteLabel = QtWidgets.QLabel( + "

" + custom_build_note + "

") + noteLabel.setWordWrap(True) licenseLabel = QtWidgets.QLabel( "

Copyright © 2012–2026 Syncplay

" + getMessage("about-dialog-license-text") + "

") @@ -175,15 +180,20 @@ def __init__(self, parent=None): aboutLayout.addWidget(nameLabel, 3, 0, 1, 4) aboutLayout.addWidget(linkLabel, 4, 0, 1, 4) aboutLayout.addWidget(versionLabel, 5, 0, 1, 4) - aboutLayout.addWidget(licenseLabel, 6, 0, 1, 4) + nextRow = 6 + if noteLabel: + aboutLayout.addWidget(noteLabel, nextRow, 0, 1, 4) + nextRow += 1 + aboutLayout.addWidget(licenseLabel, nextRow, 0, 1, 4) + nextRow += 1 licenseButton = QtWidgets.QPushButton(getMessage("about-dialog-license-button")) licenseButton.setAutoDefault(False) licenseButton.clicked.connect(self.openLicense) - aboutLayout.addWidget(licenseButton, 7, 0, 1, 2) + aboutLayout.addWidget(licenseButton, nextRow, 0, 1, 2) dependenciesButton = QtWidgets.QPushButton(getMessage("about-dialog-dependencies")) dependenciesButton.setAutoDefault(False) dependenciesButton.clicked.connect(self.openDependencies) - aboutLayout.addWidget(dependenciesButton, 7, 2, 1, 2) + aboutLayout.addWidget(dependenciesButton, nextRow, 2, 1, 2) aboutLayout.setVerticalSpacing(10) aboutLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) self.setSizeGripEnabled(False) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_strm_identity.py b/tests/test_strm_identity.py new file mode 100644 index 000000000..ae7b5080f --- /dev/null +++ b/tests/test_strm_identity.py @@ -0,0 +1,107 @@ +import os +import unittest + +from syncplay.constants import STRM_RESOLVE_TIMEOUT +from syncplay.strm import StrmIdentityTracker + + +class FakeClock: + def __init__(self): + self.now = 1000.0 + + def time(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class StrmIdentityTrackerTest(unittest.TestCase): + def setUp(self): + self.clock = FakeClock() + self.tracker = StrmIdentityTracker(time_fn=self.clock.time) + + def test_timeout_constant_is_25_seconds(self): + self.assertEqual(STRM_RESOLVE_TIMEOUT, 25) + + def test_resolved_url_within_timeout_keeps_strm_identity(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + self.tracker.begin_open(strm_path) + self.clock.advance(19) + filename, path, size = self.tracker.canonicalize( + "video.mkv", + "http://192.168.193.180:5211/api/strm/play/show.mkv", + ) + self.assertEqual(filename, os.path.basename(strm_path)) + self.assertEqual(path, strm_path) + self.assertEqual(size, 0) + + def test_resolved_url_after_timeout_drops_strm_identity(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + url = "http://192.168.193.180:5211/api/strm/play/show.mkv" + self.tracker.begin_open(strm_path) + self.clock.advance(25.1) + filename, path, size = self.tracker.canonicalize("video.mkv", url) + self.assertEqual(filename, url) + self.assertEqual(path, url) + self.assertIsNone(size) + + def test_url_just_inside_25_seconds_still_keeps_identity(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + self.tracker.begin_open(strm_path) + self.clock.advance(25) + filename, path, size = self.tracker.canonicalize( + "video.mkv", + "http://192.168.193.180:5211/api/strm/play/show.mkv", + ) + self.assertEqual(filename, os.path.basename(strm_path)) + self.assertEqual(path, strm_path) + self.assertEqual(size, 0) + + def test_player_reported_strm_path_then_url_keeps_identity(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + filename, path, size = self.tracker.canonicalize("show S06E01.strm", strm_path) + self.assertEqual(filename, os.path.basename(strm_path)) + self.assertEqual(path, strm_path) + self.assertEqual(size, 0) + self.clock.advance(19) + filename, path, size = self.tracker.canonicalize( + "video.mkv", + "http://192.168.193.180:5211/api/strm/play/show.mkv", + ) + self.assertEqual(filename, os.path.basename(strm_path)) + self.assertEqual(path, strm_path) + self.assertEqual(size, 0) + + def test_bound_url_stays_mapped_after_timeout(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + url = "http://192.168.193.180:5211/api/strm/play/show.mkv" + self.tracker.begin_open(strm_path) + self.clock.advance(5) + self.tracker.canonicalize("video.mkv", url) + self.clock.advance(40) + filename, path, size = self.tracker.canonicalize("video.mkv", url) + self.assertEqual(filename, os.path.basename(strm_path)) + self.assertEqual(path, strm_path) + self.assertEqual(size, 0) + + def test_different_local_file_clears_identity(self): + strm_path = r"Z:\litepan\strm\115\show S06E01.strm" + self.tracker.begin_open(strm_path) + filename, path, size = self.tracker.canonicalize("other.mkv", r"D:\videos\other.mkv") + self.assertEqual(filename, "other.mkv") + self.assertEqual(path, r"D:\videos\other.mkv") + self.assertIsNone(size) + + +class MpvStartupScriptTest(unittest.TestCase): + def test_mpv_startup_appends_strm_sidecar_script(self): + from syncplay.strm import add_strm_sidecar_script + args = add_strm_sidecar_script({}) + sidecar = args["scripts_append"].replace("\\", "/") + self.assertTrue(sidecar.endswith("resources/strm-sidecars.lua")) + self.assertTrue(os.path.isfile(args["scripts_append"])) + + +if __name__ == "__main__": + unittest.main() From 9f919d2306147141a349aad3c3bf8e6da2225a5e Mon Sep 17 00:00:00 2001 From: lssb Date: Sat, 19 Sep 2026 02:34:06 +0800 Subject: [PATCH 2/4] Trigger GitHub Actions build after enabling workflows. Co-authored-by: Cursor From 2da2f59129b70621d101bf37fa8362c777be3820 Mon Sep 17 00:00:00 2001 From: lssb Date: Sat, 19 Sep 2026 02:34:51 +0800 Subject: [PATCH 3/4] Allow manually triggering the Windows client build. Co-authored-by: Cursor --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f77f5ace7..c759990c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,8 @@ name: Build -on: [push, pull_request] +on: + push: + pull_request: + workflow_dispatch: jobs: windows32: From 66ced3573832991ae7fcda5fae4ee99703b959a1 Mon Sep 17 00:00:00 2001 From: lssb Date: Sat, 19 Sep 2026 02:35:33 +0800 Subject: [PATCH 4/4] Document the .strm identity and subtitle changes in README. Co-authored-by: Cursor --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cfdb76e2c..f8741a8c0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ --> # Syncplay -![GitHub Actions build status](https://github.com/Syncplay/syncplay/workflows/Build/badge.svg) +![GitHub Actions build status](https://github.com/lssb/syncplay/workflows/Build/badge.svg) Solution to synchronize video playback across multiple instances of mpv, VLC, MPC-HC, MPC-BE and mplayer2 over the Internet. @@ -32,6 +32,25 @@ https://syncplay.pl ## Download https://syncplay.pl/download/ +## This fork (lssb-strm) + +This is a personal build of Syncplay 1.7.7 for watching **`.strm` libraries** (Emby / Jellyfin / NAS pointers) together. It does not replace upstream Syncplay; the official project remains at https://github.com/Syncplay/syncplay. + +Related report: [Syncplay/syncplay#807](https://github.com/Syncplay/syncplay/issues/807). + +### What changed + +- **Shared playlist identity.** mpv treats `.strm` as a playlist and reports the inner HTTP URL as the current file. This build keeps the `.strm` filename as the room identity for 25 seconds after open, so two people can each play their own NAS URL while staying on the same playlist item. +- **Sidecar subtitles.** Syncplay starts mpv with a bundled `strm-sidecars.lua` (`--scripts-append`). Matching `.srt` / `.ass` files next to the `.strm` are loaded automatically. You do not need to change the mpv install. +- **Server.** No server change. A 1.7.7 server is fine. +- **About dialog.** Window title is `Syncplay v1.7.7 lssb-strm`. Help → About names **lssb** and **白小九日**. + +### 本次修改(中文) + +mpv 会把 `.strm` 展开成里面的 URL,Syncplay 会把它当成另一个文件,同名字幕也找不到。本 fork 做了两件事:房间里继续用 `.strm` 文件名(打开后 25 秒内完成绑定),启动 mpv 时带上内置脚本,从 `.strm` 所在目录加载同名字幕。服务端不用改。 + +Windows 64 位便携包请到本仓库 [Actions](https://github.com/lssb/syncplay/actions) 下载 `Syncplay_1.7.7_x64_Portable`(工作流名:Build,分支 `strm-identity`)。 + ## What does it do Syncplay synchronises the position and play state of multiple media players so that the viewers can watch the same thing at the same time.