Refactor media sync handling (#2647)

* Refactor media sync handling

- The media USN is now returned in sync/meta, which avoids an extra
round-trip.
- Media syncing is now automatically started by the syncing code at
the end of a normal or full sync, which avoids it competing for bandwidth
and resources, and avoids duplicate invalid login messages when the auth
token is invalid.
- Added a new media_sync_progress() method to both check if media is
syncing, and get access to the latest progress.
- Updated the sync log screen to only show the latest line, like AnkiMobile.
- Show media sync errors in a pop-up, so they don't get missed. Use a non-modal
pop-up to avoid potential conflicts with other modals.

* Remove print statement
This commit is contained in:
Damien Elmes 2023-09-10 13:22:20 +10:00 committed by GitHub
parent 89854ac2b9
commit bfef908c6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 263 additions and 180 deletions

View file

@ -8,6 +8,7 @@ option java_multiple_files = true;
package anki.collection; package anki.collection;
import "anki/generic.proto"; import "anki/generic.proto";
import "anki/sync.proto";
service CollectionService { service CollectionService {
rpc CheckDatabase(generic.Empty) returns (CheckDatabaseResponse); rpc CheckDatabase(generic.Empty) returns (CheckDatabaseResponse);
@ -100,12 +101,6 @@ message OpChangesAfterUndo {
} }
message Progress { message Progress {
message MediaSync {
string checked = 1;
string added = 2;
string removed = 3;
}
message FullSync { message FullSync {
uint32 transferred = 1; uint32 transferred = 1;
uint32 total = 2; uint32 total = 2;
@ -136,7 +131,7 @@ message Progress {
oneof value { oneof value {
generic.Empty none = 1; generic.Empty none = 1;
MediaSync media_sync = 2; sync.MediaSyncProgress media_sync = 2;
string media_check = 3; string media_check = 3;
FullSync full_sync = 4; FullSync full_sync = 4;
NormalSync normal_sync = 5; NormalSync normal_sync = 5;

View file

@ -9,17 +9,19 @@ package anki.sync;
import "anki/generic.proto"; import "anki/generic.proto";
/// Syncing methods are only available with a Backend handle. // Syncing methods are only available with a Backend handle.
service SyncService {} service SyncService {}
service BackendSyncService { service BackendSyncService {
rpc SyncMedia(SyncAuth) returns (generic.Empty); rpc SyncMedia(SyncAuth) returns (generic.Empty);
rpc AbortMediaSync(generic.Empty) returns (generic.Empty); rpc AbortMediaSync(generic.Empty) returns (generic.Empty);
// Can be used by the frontend to detect an active sync. If the sync aborted
// with an error, the next call to this method will return the error.
rpc MediaSyncStatus(generic.Empty) returns (MediaSyncStatusResponse);
rpc SyncLogin(SyncLoginRequest) returns (SyncAuth); rpc SyncLogin(SyncLoginRequest) returns (SyncAuth);
rpc SyncStatus(SyncAuth) returns (SyncStatusResponse); rpc SyncStatus(SyncAuth) returns (SyncStatusResponse);
rpc SyncCollection(SyncAuth) returns (SyncCollectionResponse); rpc SyncCollection(SyncCollectionRequest) returns (SyncCollectionResponse);
rpc FullUpload(SyncAuth) returns (generic.Empty); rpc FullUploadOrDownload(FullUploadOrDownloadRequest) returns (generic.Empty);
rpc FullDownload(SyncAuth) returns (generic.Empty);
rpc AbortSync(generic.Empty) returns (generic.Empty); rpc AbortSync(generic.Empty) returns (generic.Empty);
} }
@ -45,6 +47,11 @@ message SyncStatusResponse {
optional string new_endpoint = 4; optional string new_endpoint = 4;
} }
message SyncCollectionRequest {
SyncAuth auth = 1;
bool sync_media = 2;
}
message SyncCollectionResponse { message SyncCollectionResponse {
enum ChangesRequired { enum ChangesRequired {
NO_CHANGES = 0; NO_CHANGES = 0;
@ -60,4 +67,23 @@ message SyncCollectionResponse {
string server_message = 2; string server_message = 2;
ChangesRequired required = 3; ChangesRequired required = 3;
optional string new_endpoint = 4; optional string new_endpoint = 4;
int32 server_media_usn = 5;
}
message MediaSyncStatusResponse {
bool active = 1;
MediaSyncProgress progress = 2;
}
message MediaSyncProgress {
string checked = 1;
string added = 2;
string removed = 3;
}
message FullUploadOrDownloadRequest {
SyncAuth auth = 1;
bool upload = 2;
// if not provided, media syncing will be skipped
optional int32 server_usn = 3;
} }

View file

@ -16,6 +16,7 @@ from anki import (
links_pb2, links_pb2,
search_pb2, search_pb2,
stats_pb2, stats_pb2,
sync_pb2,
) )
from anki._legacy import DeprecatedNamesMixin, deprecated from anki._legacy import DeprecatedNamesMixin, deprecated
from anki.sync_pb2 import SyncLoginRequest from anki.sync_pb2 import SyncLoginRequest
@ -49,6 +50,7 @@ AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest
GetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse GetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse
AddonInfo = ankiweb_pb2.AddonInfo AddonInfo = ankiweb_pb2.AddonInfo
CheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse CheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse
MediaSyncStatus = sync_pb2.MediaSyncStatusResponse
import copy import copy
import os import os
@ -1246,11 +1248,14 @@ class Collection(DeprecatedNamesMixin):
def abort_sync(self) -> None: def abort_sync(self) -> None:
self._backend.abort_sync() self._backend.abort_sync()
def full_upload(self, auth: SyncAuth) -> None: def full_upload_or_download(
self._backend.full_upload(auth) self, *, auth: SyncAuth, server_usn: int | None, upload: bool
) -> None:
def full_download(self, auth: SyncAuth) -> None: self._backend.full_upload_or_download(
self._backend.full_download(auth) sync_pb2.FullUploadOrDownloadRequest(
auth=auth, server_usn=server_usn, upload=upload
)
)
def sync_login( def sync_login(
self, username: str, password: str, endpoint: str | None self, username: str, password: str, endpoint: str | None
@ -1259,8 +1264,8 @@ class Collection(DeprecatedNamesMixin):
SyncLoginRequest(username=username, password=password, endpoint=endpoint) SyncLoginRequest(username=username, password=password, endpoint=endpoint)
) )
def sync_collection(self, auth: SyncAuth) -> SyncOutput: def sync_collection(self, auth: SyncAuth, sync_media: bool) -> SyncOutput:
return self._backend.sync_collection(auth) return self._backend.sync_collection(auth=auth, sync_media=sync_media)
def sync_media(self, auth: SyncAuth) -> None: def sync_media(self, auth: SyncAuth) -> None:
self._backend.sync_media(auth) self._backend.sync_media(auth)
@ -1268,6 +1273,10 @@ class Collection(DeprecatedNamesMixin):
def sync_status(self, auth: SyncAuth) -> SyncStatus: def sync_status(self, auth: SyncAuth) -> SyncStatus:
return self._backend.sync_status(auth) return self._backend.sync_status(auth)
def media_sync_status(self) -> MediaSyncStatus:
"This will throw if the sync failed with an error."
return self._backend.media_sync_status()
def get_preferences(self) -> Preferences: def get_preferences(self) -> Preferences:
return self._backend.get_preferences() return self._backend.get_preferences()

View file

@ -6,8 +6,8 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>557</width> <width>482</width>
<height>295</height> <height>90</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@ -15,12 +15,15 @@
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
<widget class="QPlainTextEdit" name="plainTextEdit"> <widget class="QLabel" name="log_label">
<property name="readOnly"> <property name="text">
<bool>true</bool> <string notr="true">TextLabel</string>
</property> </property>
<property name="plainText"> <property name="textFormat">
<string notr="true"/> <enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property> </property>
</widget> </widget>
</item> </item>

View file

@ -1019,9 +1019,6 @@ title="{}" {}>{}</button>""".format(
def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None: def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None:
"Caller should ensure auth available." "Caller should ensure auth available."
# start media sync if not already running
if not self.media_syncer.is_syncing():
self.media_syncer.start()
def on_collection_sync_finished() -> None: def on_collection_sync_finished() -> None:
self.col.clear_python_undo() self.col.clear_python_undo()

View file

@ -5,110 +5,93 @@ from __future__ import annotations
import time import time
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import dataclass from datetime import datetime
from typing import Any, Callable, Union from typing import Any, Callable
import aqt import aqt
import aqt.forms import aqt.forms
import aqt.main import aqt.main
from anki.collection import Progress from anki.collection import Collection
from anki.errors import Interrupted from anki.errors import Interrupted
from anki.types import assert_exhaustive
from anki.utils import int_time from anki.utils import int_time
from aqt import gui_hooks from aqt import gui_hooks
from aqt.qt import QDialog, QDialogButtonBox, QPushButton, QTextCursor, QTimer, qconnect from aqt.operations import QueryOp
from aqt.utils import disable_help_button, tr from aqt.qt import QDialog, QDialogButtonBox, QPushButton, Qt, QTimer, qconnect
from aqt.utils import disable_help_button, show_info, tr
LogEntry = Union[Progress.MediaSync, str]
@dataclass
class LogEntryWithTime:
time: int
entry: LogEntry
class MediaSyncer: class MediaSyncer:
def __init__(self, mw: aqt.main.AnkiQt) -> None: def __init__(self, mw: aqt.main.AnkiQt) -> None:
self.mw = mw self.mw = mw
self._syncing: bool = False self._syncing: bool = False
self._log: list[LogEntryWithTime] = [] self.last_progress = ""
self._progress_timer: QTimer | None = None self._last_progress_at = 0
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop) gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)
def _on_progress(self) -> None:
progress = self.mw.col.latest_progress()
if not progress.HasField("media_sync"):
return
sync_progress = progress.media_sync
self._log_and_notify(sync_progress)
def start(self) -> None: def start(self) -> None:
"Start media syncing in the background, if it's not already running." "Start media syncing in the background, if it's not already running."
if not self.mw.pm.media_syncing_enabled() or not (
auth := self.mw.pm.sync_auth()
):
return
def run(col: Collection) -> None:
col.sync_media(auth)
# this will exit after the thread is spawned, but may block if there's an existing
# backend lock
QueryOp(parent=aqt.mw, op=run, success=lambda _: 1).run_in_background()
self.start_monitoring()
def start_monitoring(self) -> None:
if self._syncing: if self._syncing:
return return
if not self.mw.pm.media_syncing_enabled():
self._log_and_notify(tr.sync_media_disabled())
return
auth = self.mw.pm.sync_auth()
if auth is None:
return
self._log_and_notify(tr.sync_media_starting())
self._syncing = True self._syncing = True
self._progress_timer = self.mw.progress.timer(
1000, self._on_progress, True, True, parent=self.mw
)
gui_hooks.media_sync_did_start_or_stop(True) gui_hooks.media_sync_did_start_or_stop(True)
self._update_progress(tr.sync_media_starting())
def run() -> None: def monitor() -> None:
self.mw.col.sync_media(auth) while True:
resp = self.mw.col.media_sync_status()
if not resp.active:
return
if p := resp.progress:
self._update_progress(f"{p.added}, {p.removed}, {p.checked}")
self.mw.taskman.run_in_background(run, self._on_finished) time.sleep(0.25)
def _log_and_notify(self, entry: LogEntry) -> None: self.mw.taskman.run_in_background(monitor, self._on_finished)
entry_with_time = LogEntryWithTime(time=int_time(), entry=entry)
self._log.append(entry_with_time) def _update_progress(self, progress: str) -> None:
self.mw.taskman.run_on_main( self.last_progress = progress
lambda: gui_hooks.media_sync_did_progress(entry_with_time) self.mw.taskman.run_on_main(lambda: gui_hooks.media_sync_did_progress(progress))
)
def _on_finished(self, future: Future) -> None: def _on_finished(self, future: Future) -> None:
self._syncing = False self._syncing = False
if self._progress_timer: self._last_progress_at = int_time()
self._progress_timer.stop()
self._progress_timer.deleteLater()
self._progress_timer = None
gui_hooks.media_sync_did_start_or_stop(False) gui_hooks.media_sync_did_start_or_stop(False)
exc = future.exception() exc = future.exception()
if exc is not None: if exc is not None:
self._handle_sync_error(exc) self._handle_sync_error(exc)
else: else:
self._log_and_notify(tr.sync_media_complete()) self._update_progress(tr.sync_media_complete())
def _handle_sync_error(self, exc: BaseException) -> None: def _handle_sync_error(self, exc: BaseException) -> None:
if isinstance(exc, Interrupted): if isinstance(exc, Interrupted):
self._log_and_notify(tr.sync_media_aborted()) self._update_progress(tr.sync_media_aborted())
return return
else: else:
# Avoid popups for errors; they can cause a deadlock if show_info(str(exc), modality=Qt.WindowModality.NonModal)
# a modal window happens to be active, or a duplicate auth
# failed message if the password is changed.
self._log_and_notify(str(exc))
return return
def entries(self) -> list[LogEntryWithTime]:
return self._log
def abort(self) -> None: def abort(self) -> None:
if not self.is_syncing(): if not self.is_syncing():
return return
self._log_and_notify(tr.sync_media_aborting())
self.mw.col.set_wants_abort() self.mw.col.set_wants_abort()
self.mw.col.abort_media_sync() self.mw.col.abort_media_sync()
self._update_progress(tr.sync_media_aborting())
def is_syncing(self) -> bool: def is_syncing(self) -> bool:
return self._syncing return self._syncing
@ -140,11 +123,7 @@ class MediaSyncer:
if self.is_syncing(): if self.is_syncing():
return 0 return 0
if self._log: return int_time() - self._last_progress_at
last = self._log[-1].time
else:
last = 0
return int_time() - last
class MediaSyncDialog(QDialog): class MediaSyncDialog(QDialog):
@ -172,10 +151,7 @@ class MediaSyncDialog(QDialog):
gui_hooks.media_sync_did_progress.append(self._on_log_entry) gui_hooks.media_sync_did_progress.append(self._on_log_entry)
gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop) gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)
self.form.plainTextEdit.setPlainText( self._on_log_entry(syncer.last_progress)
"\n".join(self._entry_to_text(x) for x in syncer.entries())
)
self.form.plainTextEdit.moveCursor(QTextCursor.MoveOperation.End)
self.show() self.show()
def reject(self) -> None: def reject(self) -> None:
@ -197,24 +173,11 @@ class MediaSyncDialog(QDialog):
self._syncer.abort() self._syncer.abort()
self.abort_button.setHidden(True) self.abort_button.setHidden(True)
def _time_and_text(self, stamp: int, text: str) -> str: def _on_log_entry(self, entry: str) -> None:
asctime = time.asctime(time.localtime(stamp)) dt = datetime.fromtimestamp(int_time())
return f"{asctime}: {text}" time = dt.strftime("%H:%M:%S")
text = f"{time}: {entry}"
def _entry_to_text(self, entry: LogEntryWithTime) -> str: self.form.log_label.setText(text)
if isinstance(entry.entry, str):
txt = entry.entry
elif isinstance(entry.entry, Progress.MediaSync):
txt = self._logentry_to_text(entry.entry)
else:
assert_exhaustive(entry.entry)
return self._time_and_text(entry.time, txt)
def _logentry_to_text(self, e: Progress.MediaSync) -> str:
return f"{e.added}, {e.removed}, {e.checked}"
def _on_log_entry(self, entry: LogEntryWithTime) -> None:
self.form.plainTextEdit.appendPlainText(self._entry_to_text(entry))
if not self._syncer.is_syncing(): if not self._syncer.is_syncing():
self.abort_button.setHidden(True) self.abort_button.setHidden(True)

View file

@ -113,14 +113,15 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
if out.server_message: if out.server_message:
showText(out.server_message) showText(out.server_message)
if out.required == out.NO_CHANGES: if out.required == out.NO_CHANGES:
# all done # all done; track media progress
mw.media_syncer.start_monitoring()
return on_done() return on_done()
else: else:
full_sync(mw, out, on_done) full_sync(mw, out, on_done)
mw.col.save(trx=False) mw.col.save(trx=False)
mw.taskman.with_progress( mw.taskman.with_progress(
lambda: mw.col.sync_collection(auth), lambda: mw.col.sync_collection(auth, mw.pm.media_syncing_enabled()),
on_future_done, on_future_done,
label=tr.sync_checking(), label=tr.sync_checking(),
immediate=True, immediate=True,
@ -130,10 +131,11 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
def full_sync( def full_sync(
mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None] mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None]
) -> None: ) -> None:
server_usn = out.server_media_usn if mw.pm.media_syncing_enabled() else None
if out.required == out.FULL_DOWNLOAD: if out.required == out.FULL_DOWNLOAD:
confirm_full_download(mw, on_done) confirm_full_download(mw, server_usn, on_done)
elif out.required == out.FULL_UPLOAD: elif out.required == out.FULL_UPLOAD:
full_upload(mw, on_done) full_upload(mw, server_usn, on_done)
else: else:
button_labels: list[str] = [ button_labels: list[str] = [
tr.sync_upload_to_ankiweb(), tr.sync_upload_to_ankiweb(),
@ -143,9 +145,9 @@ def full_sync(
def callback(choice: int) -> None: def callback(choice: int) -> None:
if choice == 0: if choice == 0:
full_upload(mw, on_done) full_upload(mw, server_usn, on_done)
elif choice == 1: elif choice == 1:
full_download(mw, on_done) full_download(mw, server_usn, on_done)
else: else:
on_done() on_done()
@ -157,13 +159,15 @@ def full_sync(
) )
def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: def confirm_full_download(
mw: aqt.main.AnkiQt, server_usn: int, on_done: Callable[[], None]
) -> None:
# confirmation step required, as some users customize their notetypes # confirmation step required, as some users customize their notetypes
# in an empty collection, then want to upload them # in an empty collection, then want to upload them
if not askUser(tr.sync_confirm_empty_download()): if not askUser(tr.sync_confirm_empty_download()):
return on_done() return on_done()
else: else:
mw.closeAllWindows(lambda: full_download(mw, on_done)) mw.closeAllWindows(lambda: full_download(mw, server_usn, on_done))
def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None: def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None:
@ -185,7 +189,9 @@ def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None:
mw.col.abort_sync() mw.col.abort_sync()
def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: def full_download(
mw: aqt.main.AnkiQt, server_usn: int, on_done: Callable[[], None]
) -> None:
label = tr.sync_downloading_from_ankiweb() label = tr.sync_downloading_from_ankiweb()
def on_timer() -> None: def on_timer() -> None:
@ -201,7 +207,9 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
def download() -> None: def download() -> None:
mw.create_backup_now() mw.create_backup_now()
mw.col.close_for_full_sync() mw.col.close_for_full_sync()
mw.col.full_download(mw.pm.sync_auth()) mw.col.full_upload_or_download(
auth=mw.pm.sync_auth(), server_usn=server_usn, upload=False
)
def on_future_done(fut: Future) -> None: def on_future_done(fut: Future) -> None:
timer.stop() timer.stop()
@ -211,7 +219,7 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
fut.result() fut.result()
except Exception as err: except Exception as err:
handle_sync_error(mw, err) handle_sync_error(mw, err)
mw.media_syncer.start() mw.media_syncer.start_monitoring()
return on_done() return on_done()
mw.taskman.with_progress( mw.taskman.with_progress(
@ -220,7 +228,9 @@ def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
) )
def full_upload(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: def full_upload(
mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]
) -> None:
gui_hooks.collection_will_temporarily_close(mw.col) gui_hooks.collection_will_temporarily_close(mw.col)
mw.col.close_for_full_sync() mw.col.close_for_full_sync()
@ -242,11 +252,13 @@ def full_upload(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
except Exception as err: except Exception as err:
handle_sync_error(mw, err) handle_sync_error(mw, err)
return on_done() return on_done()
mw.media_syncer.start() mw.media_syncer.start_monitoring()
return on_done() return on_done()
mw.taskman.with_progress( mw.taskman.with_progress(
lambda: mw.col.full_upload(mw.pm.sync_auth()), lambda: mw.col.full_upload_or_download(
auth=mw.pm.sync_auth(), server_usn=server_usn, upload=True
),
on_future_done, on_future_done,
) )

View file

@ -141,12 +141,13 @@ class MessageBox(QMessageBox):
buttons: Sequence[str | QMessageBox.StandardButton] | None = None, buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
default_button: int = 0, default_button: int = 0,
textFormat: Qt.TextFormat = Qt.TextFormat.PlainText, textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
modality: Qt.WindowModality = Qt.WindowModality.WindowModal,
) -> None: ) -> None:
parent = parent or aqt.mw.app.activeWindow() or aqt.mw parent = parent or aqt.mw.app.activeWindow() or aqt.mw
super().__init__(parent) super().__init__(parent)
self.setText(text) self.setText(text)
self.setWindowTitle(title) self.setWindowTitle(title)
self.setWindowModality(Qt.WindowModality.WindowModal) self.setWindowModality(modality)
self.setIcon(icon) self.setIcon(icon)
if icon == QMessageBox.Icon.Question and theme_manager.night_mode: if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
img = self.iconPixmap().toImage() img = self.iconPixmap().toImage()

View file

@ -881,7 +881,7 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
), ),
Hook( Hook(
name="media_sync_did_progress", name="media_sync_did_progress",
args=["entry: aqt.mediasync.LogEntryWithTime"], args=["entry: str"],
), ),
Hook(name="media_sync_did_start_or_stop", args=["running: bool"]), Hook(name="media_sync_did_start_or_stop", args=["running: bool"]),
Hook( Hook(

View file

@ -17,7 +17,7 @@ pub enum Error {
cmdline: String, cmdline: String,
source: std::io::Error, source: std::io::Error,
}, },
#[snafu(display("Fail with code {code:?}: {cmdline}"))] #[snafu(display("Failed with code {code:?}: {cmdline}"))]
ReturnedError { cmdline: String, code: Option<i32> }, ReturnedError { cmdline: String, code: Option<i32> },
#[snafu(display("Couldn't decode stdout/stderr as utf8"))] #[snafu(display("Couldn't decode stdout/stderr as utf8"))]
InvalidUtf8 { InvalidUtf8 {

View file

@ -55,6 +55,7 @@ pub struct BackendInner {
runtime: OnceCell<Runtime>, runtime: OnceCell<Runtime>,
state: Mutex<BackendState>, state: Mutex<BackendState>,
backup_task: Mutex<Option<JoinHandle<Result<()>>>>, backup_task: Mutex<Option<JoinHandle<Result<()>>>>,
media_sync_task: Mutex<Option<JoinHandle<Result<()>>>>,
web_client: OnceCell<Client>, web_client: OnceCell<Client>,
} }
@ -89,6 +90,7 @@ impl Backend {
runtime: OnceCell::new(), runtime: OnceCell::new(),
state: Mutex::new(BackendState::default()), state: Mutex::new(BackendState::default()),
backup_task: Mutex::new(None), backup_task: Mutex::new(None),
media_sync_task: Mutex::new(None),
web_client: OnceCell::new(), web_client: OnceCell::new(),
})) }))
} }

View file

@ -2,15 +2,16 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::sync::sync_status_response::Required; use anki_proto::sync::sync_status_response::Required;
use anki_proto::sync::MediaSyncStatusResponse;
use anki_proto::sync::SyncStatusResponse; use anki_proto::sync::SyncStatusResponse;
use futures::future::AbortHandle; use futures::future::AbortHandle;
use futures::future::AbortRegistration; use futures::future::AbortRegistration;
use futures::future::Abortable; use futures::future::Abortable;
use reqwest::Url; use reqwest::Url;
use tracing::warn;
use super::Backend; use super::Backend;
use crate::prelude::*; use crate::prelude::*;
use crate::services::BackendCollectionService;
use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::ClientSyncState;
use crate::sync::collection::normal::SyncActionRequired; use crate::sync::collection::normal::SyncActionRequired;
use crate::sync::collection::normal::SyncOutput; use crate::sync::collection::normal::SyncOutput;
@ -67,6 +68,7 @@ impl From<SyncOutput> for anki_proto::sync::SyncCollectionResponse {
anki_proto::sync::sync_collection_response::ChangesRequired::NormalSync as i32 anki_proto::sync::sync_collection_response::ChangesRequired::NormalSync as i32
} }
}, },
server_media_usn: o.server_media_usn.0,
} }
} }
} }
@ -96,7 +98,12 @@ impl TryFrom<anki_proto::sync::SyncAuth> for SyncAuth {
impl crate::services::BackendSyncService for Backend { impl crate::services::BackendSyncService for Backend {
fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> { fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> {
self.sync_media_inner(input).map(Into::into) let auth = input.try_into()?;
self.sync_media_in_background(auth, None).map(Into::into)
}
fn media_sync_status(&self) -> Result<MediaSyncStatusResponse> {
self.get_media_sync_status()
} }
fn abort_sync(&self) -> Result<()> { fn abort_sync(&self) -> Result<()> {
@ -131,23 +138,26 @@ impl crate::services::BackendSyncService for Backend {
fn sync_collection( fn sync_collection(
&self, &self,
input: anki_proto::sync::SyncAuth, input: anki_proto::sync::SyncCollectionRequest,
) -> Result<anki_proto::sync::SyncCollectionResponse> { ) -> Result<anki_proto::sync::SyncCollectionResponse> {
self.sync_collection_inner(input) self.sync_collection_inner(input)
} }
fn full_upload(&self, input: anki_proto::sync::SyncAuth) -> Result<()> { fn full_upload_or_download(
self.full_sync_inner(input, true)?; &self,
Ok(()) input: anki_proto::sync::FullUploadOrDownloadRequest,
} ) -> Result<()> {
self.full_sync_inner(
fn full_download(&self, input: anki_proto::sync::SyncAuth) -> Result<()> { input.auth.or_invalid("missing auth")?,
self.full_sync_inner(input, false)?; input.server_usn.map(Usn),
input.upload,
)?;
Ok(()) Ok(())
} }
} }
impl Backend { impl Backend {
/// Return a handle for regular (non-media) syncing.
fn sync_abort_handle( fn sync_abort_handle(
&self, &self,
) -> Result<( ) -> Result<(
@ -155,18 +165,8 @@ impl Backend {
AbortRegistration, AbortRegistration,
)> { )> {
let (abort_handle, abort_reg) = AbortHandle::new_pair(); let (abort_handle, abort_reg) = AbortHandle::new_pair();
// Register the new abort_handle. // Register the new abort_handle.
let old_handle = self.sync_abort.lock().unwrap().replace(abort_handle); self.sync_abort.lock().unwrap().replace(abort_handle);
if old_handle.is_some() {
// NOTE: In the future we would ideally be able to handle multiple
// abort handles by just iterating over them all in
// abort_sync). But for now, just log a warning if there was
// already one present -- but don't abort it either.
warn!(
"new sync_abort handle registered, but old one was still present (old sync job might not be cancelled on abort)"
);
}
// Clear the abort handle after the caller is done and drops the guard. // Clear the abort handle after the caller is done and drops the guard.
let guard = scopeguard::guard(self.clone(), |backend| { let guard = scopeguard::guard(self.clone(), |backend| {
backend.sync_abort.lock().unwrap().take(); backend.sync_abort.lock().unwrap().take();
@ -174,19 +174,63 @@ impl Backend {
Ok((guard, abort_reg)) Ok((guard, abort_reg))
} }
pub(super) fn sync_media_inner(&self, auth: anki_proto::sync::SyncAuth) -> Result<()> { pub(super) fn sync_media_in_background(
let auth = auth.try_into()?; &self,
// mark media sync as active auth: SyncAuth,
let (abort_handle, abort_reg) = AbortHandle::new_pair(); server_usn: Option<Usn>,
{ ) -> Result<()> {
let mut guard = self.state.lock().unwrap(); let mut task = self.media_sync_task.lock().unwrap();
if guard.sync.media_sync_abort.is_some() { if let Some(handle) = &*task {
// media sync is already active if !handle.is_finished() {
// already running
return Ok(()); return Ok(());
} else { } else {
guard.sync.media_sync_abort = Some(abort_handle); // clean up
task.take();
} }
} }
let backend = self.clone();
*task = Some(std::thread::spawn(move || {
backend.sync_media_blocking(auth, server_usn)
}));
Ok(())
}
/// True if active. Will throw if terminated with error.
fn get_media_sync_status(&self) -> Result<MediaSyncStatusResponse> {
let mut task = self.media_sync_task.lock().unwrap();
let active = if let Some(handle) = &*task {
if !handle.is_finished() {
true
} else {
match task.take().unwrap().join() {
Ok(inner_result) => inner_result?,
Err(panic) => invalid_input!("{:?}", panic),
};
false
}
} else {
false
};
let progress = self.latest_progress()?;
let progress = if let Some(anki_proto::collection::progress::Value::MediaSync(progress)) =
progress.value
{
Some(progress)
} else {
None
};
Ok(MediaSyncStatusResponse { active, progress })
}
pub(super) fn sync_media_blocking(
&self,
auth: SyncAuth,
server_usn: Option<Usn>,
) -> Result<()> {
// abort handle
let (abort_handle, abort_reg) = AbortHandle::new_pair();
self.state.lock().unwrap().sync.media_sync_abort = Some(abort_handle);
// start the sync // start the sync
let (mgr, progress) = { let (mgr, progress) = {
@ -195,11 +239,11 @@ impl Backend {
(col.media()?, col.new_progress_handler()) (col.media()?, col.new_progress_handler())
}; };
let rt = self.runtime_handle(); let rt = self.runtime_handle();
let sync_fut = mgr.sync_media(progress, auth, self.web_client().clone()); let sync_fut = mgr.sync_media(progress, auth, self.web_client().clone(), server_usn);
let abortable_sync = Abortable::new(sync_fut, abort_reg); let abortable_sync = Abortable::new(sync_fut, abort_reg);
let result = rt.block_on(abortable_sync); let result = rt.block_on(abortable_sync);
// mark inactive // clean up the handle
self.state.lock().unwrap().sync.media_sync_abort.take(); self.state.lock().unwrap().sync.media_sync_abort.take();
// return result // return result
@ -222,6 +266,7 @@ impl Backend {
drop(guard); drop(guard);
// block until it aborts // block until it aborts
while self.state.lock().unwrap().sync.media_sync_abort.is_some() { while self.state.lock().unwrap().sync.media_sync_abort.is_some() {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
self.progress_state.lock().unwrap().want_abort = true; self.progress_state.lock().unwrap().want_abort = true;
@ -297,13 +342,14 @@ impl Backend {
pub(super) fn sync_collection_inner( pub(super) fn sync_collection_inner(
&self, &self,
input: anki_proto::sync::SyncAuth, input: anki_proto::sync::SyncCollectionRequest,
) -> Result<anki_proto::sync::SyncCollectionResponse> { ) -> Result<anki_proto::sync::SyncCollectionResponse> {
let auth: SyncAuth = input.try_into()?; let auth: SyncAuth = input.auth.or_invalid("missing auth")?.try_into()?;
let (_guard, abort_reg) = self.sync_abort_handle()?; let (_guard, abort_reg) = self.sync_abort_handle()?;
let rt = self.runtime_handle(); let rt = self.runtime_handle();
let client = self.web_client().clone(); let client = self.web_client().clone();
let auth2 = auth.clone();
let ret = self.with_col(|col| { let ret = self.with_col(|col| {
let sync_fut = col.normal_sync(auth.clone(), client.clone()); let sync_fut = col.normal_sync(auth.clone(), client.clone());
@ -325,6 +371,13 @@ impl Backend {
}); });
let output: SyncOutput = ret?; let output: SyncOutput = ret?;
if input.sync_media
&& !matches!(output.required, SyncActionRequired::FullSyncRequired { .. })
{
self.sync_media_in_background(auth2, Some(output.server_media_usn))?;
}
self.state self.state
.lock() .lock()
.unwrap() .unwrap()
@ -337,9 +390,11 @@ impl Backend {
pub(super) fn full_sync_inner( pub(super) fn full_sync_inner(
&self, &self,
input: anki_proto::sync::SyncAuth, input: anki_proto::sync::SyncAuth,
server_usn: Option<Usn>,
upload: bool, upload: bool,
) -> Result<()> { ) -> Result<()> {
let auth = input.try_into()?; let auth: SyncAuth = input.try_into()?;
let auth2 = auth.clone();
self.abort_media_sync_and_wait(); self.abort_media_sync_and_wait();
let rt = self.runtime_handle(); let rt = self.runtime_handle();
@ -368,7 +423,7 @@ impl Backend {
// ensure re-opened regardless of outcome // ensure re-opened regardless of outcome
col.replace(builder.build()?); col.replace(builder.build()?);
match result { let result = match result {
Ok(sync_result) => { Ok(sync_result) => {
if sync_result.is_ok() { if sync_result.is_ok() {
self.state self.state
@ -381,7 +436,13 @@ impl Backend {
sync_result sync_result
} }
Err(_) => Err(AnkiError::Interrupted), Err(_) => Err(AnkiError::Interrupted),
};
if result.is_ok() && server_usn.is_some() {
self.sync_media_in_background(auth2, server_usn)?;
} }
result
} }
} }

View file

@ -147,10 +147,11 @@ impl MediaManager {
progress: ThrottlingProgressHandler<MediaSyncProgress>, progress: ThrottlingProgressHandler<MediaSyncProgress>,
auth: SyncAuth, auth: SyncAuth,
client: Client, client: Client,
server_usn: Option<Usn>,
) -> Result<()> { ) -> Result<()> {
let client = HttpSyncClient::new(auth, client); let client = HttpSyncClient::new(auth, client);
let mut syncer = MediaSyncer::new(self, progress, client)?; let mut syncer = MediaSyncer::new(self, progress, client)?;
syncer.sync().await syncer.sync(server_usn).await
} }
pub fn all_checksums_after_checking( pub fn all_checksums_after_checking(

View file

@ -240,11 +240,8 @@ pub(crate) fn progress_to_proto(
} }
} }
fn media_sync_progress( fn media_sync_progress(p: MediaSyncProgress, tr: &I18n) -> anki_proto::sync::MediaSyncProgress {
p: MediaSyncProgress, anki_proto::sync::MediaSyncProgress {
tr: &I18n,
) -> anki_proto::collection::progress::MediaSync {
anki_proto::collection::progress::MediaSync {
checked: tr.sync_media_checked_count(p.checked).into(), checked: tr.sync_media_checked_count(p.checked).into(),
added: tr added: tr
.sync_media_added_count(p.uploaded_files, p.downloaded_files) .sync_media_added_count(p.uploaded_files, p.downloaded_files)

View file

@ -42,6 +42,9 @@ pub struct SyncMeta {
pub host_number: u32, pub host_number: u32,
#[serde(default)] #[serde(default)]
pub empty: bool, pub empty: bool,
/// This field is not set by col.sync_meta(), and must be filled in
/// separately.
pub media_usn: Usn,
#[serde(skip)] #[serde(skip)]
pub v2_scheduler_or_later: bool, pub v2_scheduler_or_later: bool,
#[serde(skip)] #[serde(skip)]
@ -77,6 +80,7 @@ impl SyncMeta {
server_message: remote.server_message, server_message: remote.server_message,
host_number: remote.host_number, host_number: remote.host_number,
new_endpoint, new_endpoint,
server_media_usn: remote.media_usn,
} }
} }
} }
@ -132,6 +136,8 @@ impl Collection {
empty: !self.storage.have_at_least_one_card()?, empty: !self.storage.have_at_least_one_card()?,
v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2, v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2,
v2_timezone: self.get_creation_utc_offset().is_some(), v2_timezone: self.get_creation_utc_offset().is_some(),
// must be filled in by calling code
media_usn: Usn(0),
}) })
} }
} }

View file

@ -53,6 +53,7 @@ pub struct ClientSyncState {
pub(in crate::sync) server_usn: Usn, pub(in crate::sync) server_usn: Usn,
// -1 in client case; used to locate pending entries // -1 in client case; used to locate pending entries
pub(in crate::sync) pending_usn: Usn, pub(in crate::sync) pending_usn: Usn,
pub(in crate::sync) server_media_usn: Usn,
} }
impl NormalSyncer<'_> { impl NormalSyncer<'_> {
@ -139,6 +140,8 @@ pub struct SyncOutput {
pub server_message: String, pub server_message: String,
pub host_number: u32, pub host_number: u32,
pub new_endpoint: Option<String>, pub new_endpoint: Option<String>,
#[allow(unused)]
pub(crate) server_media_usn: Usn,
} }
impl From<ClientSyncState> for SyncOutput { impl From<ClientSyncState> for SyncOutput {
@ -148,6 +151,7 @@ impl From<ClientSyncState> for SyncOutput {
server_message: s.server_message, server_message: s.server_message,
host_number: s.host_number, host_number: s.host_number,
new_endpoint: s.new_endpoint, new_endpoint: s.new_endpoint,
server_media_usn: s.server_media_usn,
} }
} }
} }

View file

@ -61,7 +61,9 @@ impl SyncProtocol for Arc<SimpleServer> {
async fn meta(&self, req: SyncRequest<MetaRequest>) -> HttpResult<SyncResponse<SyncMeta>> { async fn meta(&self, req: SyncRequest<MetaRequest>) -> HttpResult<SyncResponse<SyncMeta>> {
self.with_authenticated_user(req, |user, req| { self.with_authenticated_user(req, |user, req| {
let req = req.json()?; let req = req.json()?;
user.with_col(|col| server_meta(req, col)) let mut meta = user.with_col(|col| server_meta(req, col))?;
meta.media_usn = user.media.last_usn()?;
Ok(meta)
}) })
.await .await
.and_then(SyncResponse::try_from_obj) .and_then(SyncResponse::try_from_obj)

View file

@ -50,20 +50,24 @@ impl MediaSyncer {
}) })
} }
pub async fn sync(&mut self) -> Result<()> { pub async fn sync(&mut self, server_usn: Option<Usn>) -> Result<()> {
self.sync_inner().await.map_err(|e| { self.sync_inner(server_usn).await.map_err(|e| {
debug!("sync error: {:?}", e); debug!("sync error: {:?}", e);
e e
}) })
} }
#[allow(clippy::useless_let_if_seq)] #[allow(clippy::useless_let_if_seq)]
async fn sync_inner(&mut self) -> Result<()> { async fn sync_inner(&mut self, server_usn: Option<Usn>) -> Result<()> {
self.register_changes()?; self.register_changes()?;
let meta = self.mgr.db.get_meta()?; let meta = self.mgr.db.get_meta()?;
let client_usn = meta.last_sync_usn; let client_usn = meta.last_sync_usn;
let server_usn = self.begin_sync().await?; let server_usn = if let Some(usn) = server_usn {
usn
} else {
self.begin_sync().await?
};
let mut actions_performed = false; let mut actions_performed = false;

View file

@ -151,13 +151,13 @@ impl SyncTestContext {
async fn sync_media1(&self) -> Result<()> { async fn sync_media1(&self) -> Result<()> {
let mut syncer = let mut syncer =
MediaSyncer::new(self.media1(), ignore_progress(), self.client.clone()).unwrap(); MediaSyncer::new(self.media1(), ignore_progress(), self.client.clone()).unwrap();
syncer.sync().await syncer.sync(None).await
} }
async fn sync_media2(&self) -> Result<()> { async fn sync_media2(&self) -> Result<()> {
let mut syncer = let mut syncer =
MediaSyncer::new(self.media2(), ignore_progress(), self.client.clone()).unwrap(); MediaSyncer::new(self.media2(), ignore_progress(), self.client.clone()).unwrap();
syncer.sync().await syncer.sync(None).await
} }
/// As local change detection depends on a millisecond timestamp, /// As local change detection depends on a millisecond timestamp,