Skip to content

Сохранение таймкода воспроизведения на стороне Торсервера - #818

Open
s1mptom wants to merge 11 commits into
YouROK:masterfrom
s1mptom:pr/auto-save-position
Open

s1mptom wants to merge 11 commits into
YouROK:masterfrom
s1mptom:pr/auto-save-position

Conversation

@s1mptom

@s1mptom s1mptom commented Aug 4, 2026 •

Copy link
Copy Markdown

Дисклеймер: код честно навайбкожен (как и описание далее и плагин для лампы). Решал свою давнюю боль (и успешно решил). Буду рад если данный механизм появится в апстриме (мердж ПР или кто-то решит переписать под схожую или более правильную логику)

Сохранение позиции воспроизведения

Внешний плеер, который закрылся штатно, сообщает, где остановился. Тот, который не закрылся —
телевизор выключился на паузе, Android убил приложение — не сообщает ничего, и позиция
теряется. Больнее всего на сериалах: серию потом приходится искать заново.

Здесь позиция записывается на стороне сервера, из того, что и так видно по ридеру.

Включается в Настройках → сохранять позицию воспроизведения. Нужен ffprobe:
установщики предлагают его поставить, а без него переключатель заблокирован с пояснением.

Включить достаточно одну галку. TrackTimecode — флаг хранения таймкодов, доступный только
через API, — подразумевается автоматически, второй настройки искать не нужно.

Что и когда записывается

Пока файл отдаётся, ридер знает смещение в байтах, до которого он выдал данные клиенту. Это
не то место, где находится зритель: плееру отдано всё, что лежит у него в буфере, а смотрит
он позади конца этого буфера.

позиция на экране  =  голова ридера  −  буфер клиента

Смещение переводится в секунды по длительности файла — за этим и нужен ffprobe. Результат
кладётся в запись просмотра каждые 30 секунд и ещё раз при завершении потока.

От мусора в записи защищают три условия:

  • Сессия короче 20 секунд игнорируется — это проба или чтение метаданных, а не просмотр.
  • Голова, не ушедшая дальше буфера, игнорируется: сыграно ещё ничего не было.
  • В пределах 8 МБ от конца файл считается досмотренным, и позиция ставится равной его длине,
    а не «почти концу» — иначе такое кино предлагалось бы продолжить.

Время меряется двумя способами, берётся большее. Как долго клиент держал поток, зависит от
управления потоком TCP; протяжённость реальных чтений — от того, как быстро торрент отдаёт
данные. Каждая величина по отдельности способна обмануть.

ffprobe спрашивается о файле один раз и не чаще, чем раз в десять минут, по одному процессу
за раз. Он читает через тот же самый эндпоинт отдачи, поэтому его собственный запрос несёт
пометку, исключающую его из всего вышеописанного. Без этого замер запускал бы замер.

Как меряется буфер

Чтобы вычесть буфер, его надо знать, но не все плееры о нём не сообщают (или позволяют его настраивать). Зато за ним можно
понаблюдать.

Плеер набивает буфер настолько быстро, насколько позволяет сеть, а затем переходит к
запросам примерно с той скоростью, с какой потребляет данные. Значит на достаточно длинном
отрезке ридер движется со скоростью воспроизведения, а величина, на которую он опережает
зрителя, и есть буфер:

буфер  =  максимум за сессию от  [ (прочитано − старт) − скорость × время ]

Скорость берётся с хвоста сессии, усредняется по 30-секундным окнам и принимается только
когда два соседних окна сходятся в пределах 40%. Иначе торрент, который подвис и потом
нагнал, был бы прочитан как медленный плеер.

Оценка идёт в дело лишь после того, как воспроизведение наблюдалось не меньше времени, чем
набирался отрыв: до этого отрыв неотличим от первоначального наполнения буфера. Результат
ограничен диапазоном от 4 МБ до 1 ГБ.

Замер против Vimu на 4K-ремуксе: 115 МБ при 100 МБ, выставленных в самом плеере — разница
это то, что плеер держит сверх собственной настройки.

Автозамер — отдельный переключатель, по умолчанию выключен. Когда он выключен или пока ему
не хватает данных, берётся заданное запасное значение (32 МБ, если не менять).

Плагин для Lampa

Вторая половина для пользователей Lampa: сервер хранит
позицию, плагин возвращает её в интерфейс.

https://github.com/s1mptom/lampa-ts-resume

Прямая ссылка для добавления в Lampa (Настройки → Плагины → по ссылке):

https://raw.githubusercontent.com/s1mptom/lampa-ts-resume/main/lampa_ts_resume.js

Плагин обращается только к тому адресу TorrServer, который уже прописан в настройках Lampa,
и ничего больше не требует.

Пара деталей, объясняющих, почему он вообще понадобился. Открытие торрента не поднимает в
Lampa активити — это модалка, — поэтому плагин цепляется к Torserver.files(hash), через
которую проходят все пути в список файлов. И записывает он два ключа таймлайна: карточка
хеширует серию по оригинальному названию сериала, а список файлов торрента — по
оригинальному заголовку, и правка одного оставляет второй протухшим. Выглядит это как
полоска прогресса, которая сдвинулась на одном экране и не сдвинулась на другом.

Pavel Turbin added 7 commits July 26, 2026 22:07
On reader close, save the on-screen position (read head minus the client's
buffer) into the viewed record as a 0..1 byte-fraction of the file. Duration-free
(no ffprobe): consumers multiply by media runtime. Gated by new BTSets fields
SavePosition / BufferSizeMB / AutoBuffer; a preload/probe (read < buffer) is skipped.

Reader gains startOffset (first Range-seek target) for future auto-buffer measurement.
TorrServer now records where playback actually was, so a position survives the
player never reporting back - a TV powering off, an app being killed, or an
external player that has no callback.

While a stream runs and when it ends, the position is taken as the read head
minus the client's buffer and stored in the viewed data in seconds, using the
file's real duration from ffprobe (which the feature requires, and which the
installers now offer to install). Offset, length and duration are stored
alongside so clients can show progress without guessing the runtime.

The client buffer is measured from how the player loads data: it fills as fast
as it can, then settles to the playback rate, and the difference is the buffer.
A configured size is used when that measurement is not available. Verified
against a simulated player: within ~1.2s of the true on-screen position.

Probes, preloads and short requests are ignored, and our own ffprobe stream is
marked so probing can never recurse into itself. Marking a file as viewed no
longer clears a stored position.
…able

The buffer measurement now reports what it derived and from what (fill size and
duration, measured playback rate), so the value can be reviewed after a session.

The fallback size stays editable while automatic measurement is on, since it is
what gets used until the measurement becomes available - roughly the first
minute of playback. It is labelled as the fallback in that state.
…t sooner

The buffer log was emitted for every stream, including the many short probe and
preload requests that never reach the save, which buried the useful lines. It is
now written where the value is actually used.

The steady playback phase needed before the buffer can be derived drops from 60s
to 30s: real sessions were ending before the measurement became available and
falling back to the configured size.
Unexport setDuration and probeMarker, which are only used inside the torr
package. Rename durationKey to fileKey, since both caches are keyed by it.
Name the fallback buffer constant instead of repeating a literal, and report
saving under the same [Position] prefix as the buffer measurement.

Also drop the unreachable tail of ListViewed, which go vet flags in the file
this branch rewrites, and rebuild the web assets with the locked dependency
versions.
…rthy

The playback rate is multiplied by the fill duration, so an error in the rate
grows with however long filling took. A slow line fills slowly, which made the
estimate noisy exactly where it is least affordable.

The steady phase now has to last at least as long as the fill did, and never
less than the previous 30s. Until then the configured size is used.
Filling was declared over at the first pause longer than two seconds, but a
client tops its buffer up in bursts, so pauses are normal, and a brief network
hiccup while filling looks exactly like one. That ended the measurement early
and understated the buffer, which moves the saved position forward - past
content the viewer had not reached.

The buffer is how far the reader runs ahead of playback, so it is now taken as
the largest such lead across sampled positions: bytes read beyond the start,
minus what playback consumed in the same time. A hiccup simply is not the
maximum, and neither is the low point of a top-up cycle. Playback speed comes
from the tail of the session and is only trusted once two consecutive windows
agree, which is what separates filling from playing at its own pace.

Verified against a simulated player reporting its true on-screen position:
within 1.5s of it.
@VuzzyM

VuzzyM commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

I don't think you need a plugin for TrackTimecode anymore. The Lampa already supports that natively: yumata/lampa-source#367

Pavel Turbin and others added 3 commits September 3, 2026 16:21
The position used to be a byte fraction scaled by the ffprobe duration, which is a minute out
on any file whose bitrate moves. Now the bytes streamed to the player are parsed on the way
past for the timestamps the container carries (Matroska, MP4, MPEG-TS, MPEG-PS, FLV, AVI),
building a byte-to-time index per file; the picture is the film at (read head - client
buffer), and the buffer is measured from the fill at the start of a connection rather than
taken from a setting. A reconnecting player inherits what the previous connection knew, so the
position no longer jumps by a whole buffer when a paused client drops its socket.

The byte-fraction estimate stays as the fallback for containers without timestamps, and the
BufferSizeMB setting now only applies there. SmartTimecode can turn the new path off.

Matroska: TimestampScale is read only from inside the Info element. Scanning the first
megabytes for its three-byte id matched picture data on a real 4K episode and multiplied
every timestamp by twelve and a half, which then poisoned the whole index for that file.

The web UI shows the live position and measured buffer in the torrent card and details.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion

# Conflicts:
#	server/torr/stream.go
#	server/web/pages/template/html.go
#	server/web/pages/template/pages/asset-manifest.json
#	server/web/pages/template/pages/index.html
#	server/web/pages/template/pages/static/js/2.f14cb17a.chunk.js.LICENSE.txt
#	server/web/pages/template/pages/static/js/2.f454f580.chunk.js.LICENSE.txt
#	server/web/pages/template/pages/static/js/2.f7ad277c.chunk.js.LICENSE.txt
#	server/web/pages/template/route.go
#	web/src/utils/Hosts.js
…und unused

timeindex: the six container parsers each carried their own copy of the join-the-carry and
keep-the-tail code, and two of them the collect-a-box-across-reads state machine. Both live in
timeindex.go now (tail, collector), with the offset-before-reslice rule written once. The join
reuses a scratch buffer instead of allocating a copy of every read, the emit closure is built
once per Feeder rather than per read, and a timestamp past the end of the index is appended
without the binary search — which is every timestamp on a forward stream. Frontier/Span and
the dropped flag had no readers.

torrstor/stream: min/max helpers replaced by the builtins; the unused keptUp field, watchFor
constant, handover.head and the ffprobe status fields nothing in the UI read are gone; the
local /play probe link is built in one place for both the saver and preload; the per-stream
ticker only runs at the reckoning rate when there are timestamps to reckon with.

web: one list comparator for the card's memo, comparing what the card shows rather than the
read head, and one buffer-mark helper shared by the card and the readout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@s1mptom

s1mptom commented Sep 7, 2026

Copy link
Copy Markdown
Author

Обновление: позиция читается из меток времени контейнера

Ветка слита с текущим master (MatriX.144), конфликтов больше нет. Подход к расчёту позиции изменился по сравнению с описанием выше, поэтому коротко о том, что теперь в ветке.

Что добавлено

  • Индекс времени по файлу (server/timeindex). Байты, которые и так уходят плееру, разбираются на лету, и из них берутся метки времени самого контейнера: Matroska (timestamp кластера), MP4 (таблица moov для прогрессивных файлов и tfdt для фрагментированных), MPEG-TS (PCR), MPEG-PS (PTS), FLV, AVI (idx1 + fps). Получается таблица «байт → секунда» на файл, общая для всех подключений к нему. Средний битрейт и длительность от ffprobe больше не нужны для расчёта; они остались только как запасной вариант для контейнеров без меток и для проверки «досмотрено до конца».
  • Замер буфера плеера по факту (torrstor/progress.go). Буфер измеряется один раз в начале соединения по тому, сколько плеер забрал сверх того, что мог успеть показать, и дальше только вычитается: позиция = фильм в точке (голова ридера − буфер). Ничего не накапливается со временем, поэтому нет дрейфа. На проде за четыре дня измеренный буфер держится в 200–320 МБ при настройке плеера 300 МБ.
  • Передача состояния между соединениями ([Handover]). Плеер, который отвалился на паузе и переоткрыл соединение с того же места, наследует измеренный буфер и позицию, вместо того чтобы получить скачок на целый буфер вперёд.
  • Настройка SmartTimecode (по умолчанию включена) переключает между метками контейнера и старым расчётом по битрейту. BufferSizeMB теперь используется только для файлов без меток.
  • В веб-интерфейсе: живая позиция и буфер в карточке торрента и в деталях, переключатели в Настройках → Pro → Advanced.

Что исправлено

  • Matroska: TimestampScale читался из произвольного места. Поиск трёхбайтового id шёл по первым 4 МБ файла, а заголовок занимает считанные килобайты, остальное кадры. На реальном 4K-эпизоде случайное совпадение внутри картинки дало scale 12 523 939 вместо 1 000 000, все метки умножились на 12,5 и индекс файла оказался отравлен. Теперь значение берётся только внутри элемента Info и помечается прочитанным независимо от величины. Есть регрессионный тест.
  • Гонка при записи состояния трекера из нескольких горутин (posMu), паника при закрытии кэша с активным индексом, бесконечный цикл на обрезанном 64-битном размере бокса в MP4, переполнение смещения в FLV на 32-битных сборках, потеря переноса хвоста в MPEG-TS, preload без пометки пробы перезаписывал реальную позицию.
  • Убрана вся отладочная трассировка и русскоязычные логи из первых версий.

Что проверено

go vet и go test -race для timeindex, torrstor, torr, settings, web/api зелёные. Парсеры проверяются на образцах, которые тесты собирают через ffmpeg (пропускаются, если его нет). Сборка развёрнута на боевом сервере с 3 сентября: 229 сохранений по пяти файлам, без паник и без расхождений.

По поводу замечания о Lampa: спасибо, плагин действительно больше не нужен, раз Lampa поддерживает TrackTimecode сама. Серверная часть с этим полностью совместима, запись /viewed дополнена полями offset, length, duration, старые записи читаются как прежде.

🤖 Generated with Claude Code

…ord back to timecode and duration

The viewed record keeps only what a client cannot get elsewhere: the timecode and the real
length of the file it was taken from, so progress can be shown against this file rather than
a catalogue runtime. Offset and length are gone; records written with them are still read.
SavePosition now implies TrackTimecode in the settings instead of a second check in the
store.

Reader.Playback() reads everything the tracker knows under one lock — anchor, head, picture,
buffer, session — and the status poll and the saver take that snapshot instead of a dozen
locked calls each. The reckoning is ticked from the torrent's own one-second timer for every
reader of its cache, not from a goroutine per stream, so probes and preloads are treated like
any other connection and the stream handler only saves. "Watched to the end" is decided in
seconds against the duration rather than by an eight-megabyte margin in bytes. Durations
and last-saved offsets are forgotten when a torrent closes. The status carries a viewing
flag, so the UI shows the reader the saver would trust rather than guessing by session
length.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants