}
}
-function player_command(command) {
- wrapped_fetch(PATH_PLAYER, {
+async function wrapped_post(target, body, verbose=false) {
+ const fetch_kwargs = {
method: "POST",
headers: {"Content-Type": "application/json"},
- body: JSON.stringify({command: [command]})
- });
+ body: JSON.stringify(body)
+ };
+ const response = await wrapped_fetch(target, fetch_kwargs, verbose);
+ return response;
+}
+
+function player_command(command) {
+ wrapped_post(PATH_PLAYER, {command: [command]})
}
function father_tag_links(parent_element, tags) {
{% extends '_base.tmpl' %}
+{% block script %}
+const PATH_FILE_JSON = `/{{page_names.file_json}}/` + "{{file.digest.b64}}";
+
+async function update_file_data() {
+ const file_data = await wrapped_fetch(PATH_FILE_JSON).then((response) => response.json());
+ document.getElementById("sync_checkbox").checked = ! file_data.flags.includes("do not sync");
+ Array.from(document.getElementsByClassName("listed_tags")).forEach((row) => row.remove());
+ file_data.tags_showable.forEach((tag) => {
+ const tr = new_child_to("tr", document.getElementById("tags_table"));
+ tr.classList.add("listed_tags");
+ td_checkbox = new_child_to("td", tr);
+ td_checkbox.classList.add("tag_checkboxes");
+ const input = new_child_to("input", td_checkbox);
+ input.type = "checkbox";
+ input.checked = true;
+ {% if not allow_edit %}input.disabled = true;{% endif %}
+ input.onclick = send_update;
+ const a = new_child_to("a", new_child_to("td", new_child_to("td", tr)));
+ a.href = "/{{page_names.files}}?needed_tag={{tag|urlencode}}";
+ a.textContent = tag;
+ });
+ document.getElementById("added_tag").value = "";
+ const datalist = document.getElementById("unused_tags");
+ datalist.innerHTML = "";
+ file_data.unused_tags.forEach((tag) => { new_child_to("option", datalist, tag); });
+ td_present = document.getElementById("presence");
+ td_present.innerHTML = "";
+ if (file_data.present) {
+ const a = new_child_to("a", td_present, "yes");
+ a.href = "/{{page_names.download}}/{{file.yt_id}}";
+ const button = new_child_to("button", td_present, "inject");
+ button.onclick = function() { player_command("inject_{{file.digest.b64}}"); }
+ } else {
+ td_present.textContent = "no";
+ }
+}
+
+async function send_update(button) {
+ let tags = []
+ Array.from(document.getElementsByClassName("listed_tags")).forEach((tr) => {
+ if (tr.children[0].children[0].checked) {
+ tags.push(tr.children[1].children[0].textContent);
+ }
+ });
+ const added_tag = document.getElementById("added_tag").value;
+ if (added_tag.length > 0 && button == document.getElementById("add_tag_button")) {
+ tags.push(added_tag);
+ }
+ let flags = []
+ if (! document.getElementById("sync_checkbox").checked) {
+ flags.push("do not sync");
+ }
+ await wrapped_post(PATH_FILE_JSON, {
+ flags: flags,
+ tags: tags,
+ delete_locally: button == document.getElementById("unlink_button")
+ });
+ update_file_data();
+}
+
+window.addEventListener('load', update_file_data);
+{% endblock %}
+
+
{% block css %}
td.top_field { width: 100%; }
td.tag_checkboxes { width: 1em; }
-td.dangerous, div.dangerous { text-align: right; }
-input[type=submit].dangerous { color: red; }
+td.dangerous { text-align: right}
+td.dangerous > form > input[type=submit], td.dangerous > button { color: red; }
{% endblock %}
{% block body %}
-{% if allow_edit %}
-<form action="/{{page_names.file}}/{{file.digest.b64}}" method="POST" />
-<input type="hidden" name="redir_target" value="{{redir_target}}" />
-{% endif %}
<table>
<tr>
<tr>
<th>present:</th>
-<td>
-{% if file.present %}
-<a href="/{{page_names.download}}/{{file.yt_id}}">yes</a>
-<input type="button" onclick="player_command('inject_{{file.digest.b64}}');" value="inject" />
-{% else %}
-no
-{% endif %}
+<td id="presence">
</td>
</tr>
<tr>
<th>tags</th>
<td>
-<table>
-{% for tag in file.tags_showable %}
-<tr>
-<td class="tag_checkboxes"><input type="checkbox" name="tags" value="{{tag}}" checked{% if not allow_edit %} disabled{% endif %}/></td>
-<td><a href="/{{page_names.files}}?needed_tag={{tag|urlencode}}">{{tag}}</a></td>
-</tr>
-{% endfor %}
+<table id="tags_table">
{% if allow_edit %}
<tr>
-<td class="tag_checkboxes">add:</td>
+<td class="tag_checkboxes"><button id="add_tag_button" onclick="send_update(this)" >add:</button></td>
<td>
-<input name="tags" list="unused_tags" autocomplete="off" />
+<input id="added_tag" list="unused_tags" autocomplete="off" />
<datalist id="unused_tags" />
-{% for tag in unused_tags %}
-<option value="{{tag}}">{{tag}}</option>
-{% endfor %}
</datalist>
</td>
</tr>
</tr>
<tr>
-<th>flags:</th>
+<th>options</th>
+<td>
+<table>
+<tr>
+<td>
+<input id="sync_checkbox" type="checkbox" {% if not allow_edit %}disabled{% endif %} onclick="send_update()" /> do sync<br />
+</td>
+{% if allow_edit %}
<td class="dangerous">
-{% for flag_name in flag_names %}
-{{ flag_name }}: <input type="checkbox" name="flags" value="{{flag_name}}" {% if file.is_flag_set(flag_name) %}checked{% endif %}{% if not allow_edit %} disabled{% endif %}/><br />
-{% endfor %}
+<button id="unlink_button" onclick="send_update(this)" />delete locally</button>
+<form action="/{{page_names.file_kill}}/{{file.digest.b64}}" method="POST" />
+<input type="hidden" name="redir_target" value="{{redir_target}}" />
+<input type="submit" value="KILL" />
+</form>
+</td>
+{% endif %}
+</tr>
+</table>
</td>
</tr>
</table>
-{% if allow_edit %}
-<input type="submit" name="update" value="update" />
-<div class="dangerous">
-<input class="dangerous" type="submit" name="unlink" value="unlink (locally)" />
-</div>
-</form>
-{% endif %}
{% endblock %}
'download': 'dl',
'events': 'events',
'file': 'file',
+ 'file_kill': 'file_kill',
+ 'file_json': 'file.json',
'files': 'files',
'files_json': 'files.json',
'missing': 'missing',
def do_POST(self) -> None: # pylint:disable=invalid-name
"""Map POST requests to handlers for various paths."""
- if self.pagename == PAGE_NAMES['file']:
- self._receive_file_data()
+ if self.pagename == PAGE_NAMES['file_json']:
+ self._update_file()
+ elif self.pagename == PAGE_NAMES['file_kill']:
+ self._kill_file()
elif self.pagename == PAGE_NAMES['player']:
self._receive_player_command()
elif self.pagename == PAGE_NAMES['purge']:
elif self.pagename == PAGE_NAMES['yt_queries']:
self._receive_yt_query()
- def _receive_file_data(self) -> None:
+ def _kill_file(self) -> None:
+ if not self.server.config.allow_file_edit:
+ self.send_http(b'no way', code=403)
+ with DbConn() as conn:
+ file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
+ file.set_flags({FILE_FLAGS[FlagName('delete')]})
+ file.save(conn)
+ conn.commit()
+ file.ensure_absence_if_deleted()
+ self._redirect(Path(self.postvars.first_for('redir_target')))
+
+ def _update_file(self) -> None:
if not (self.server.config.allow_file_edit # also if whitelist, …
and self.server.config.tags_display_whitelist.empty):
self.send_http(b'no way', code=403) # … cuz input form under …
return # … this display filter might have suppressed set tags
with DbConn() as conn:
file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
- if self.postvars.has_key('unlink'):
- file.unlink_locally()
- file.set_flags({FILE_FLAGS[FlagName(name)]
- for name in self.postvars.all_for('flags')})
file.tags = TagSet.from_str_list(self.postvars.all_for('tags'))
+ file.set_flags({FILE_FLAGS[FlagName(name)] for name
+ in self.postvars.all_for('flags')})
+ if self.postvars.as_dict['delete_locally']:
+ file.unlink_locally()
file.save(conn)
conn.commit()
- file.ensure_absence_if_deleted()
- self._redirect(Path(self.postvars.first_for('redir_target')))
+ self.send_http(b'OK')
def _receive_player_command(self) -> None:
command = self.postvars.first_for('command')
self._send_events()
elif self.pagename == PAGE_NAMES['file']:
self._send_file_data()
+ elif self.pagename == PAGE_NAMES['file_json']:
+ self._send_file_json()
elif self.pagename == PAGE_NAMES['files']:
self._send_files_index()
elif self.pagename == PAGE_NAMES['files_json']:
except NotFoundException as e:
self.send_http(bytes(str(e), encoding='utf8'), code=404)
+ def _send_json(self, body: dict | list) -> None:
+ self.send_http(bytes(json_dumps(body), encoding='utf8'),
+ headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)])
+
def _send_rendered_template(self,
tmpl_name: Path,
tmpl_ctx: dict[str, Any]
sleep(_EVENTS_UPDATE_INTERVAL_S)
def _send_file_data(self) -> None:
- digest = Hash.from_b64(self.path_toks[2])
with DbConn() as conn:
- file = VideoFile.get_one(conn, digest)
- unused_tags = file.unused_tags(conn)
+ file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
self._send_rendered_template(
_NAME_TEMPLATE_FILE_DATA,
{'allow_edit': self.server.config.allow_file_edit,
'file': file,
- 'flag_names': list(FILE_FLAGS),
- 'redir_target': self.path,
- 'unused_tags': unused_tags})
+ 'redir_target': self.path})
+
+ def _send_file_json(self) -> None:
+ with DbConn() as conn:
+ file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
+ unused_tags = file.unused_tags(conn).as_str_list
+ self._send_json(file.as_dict | {'unused_tags': unused_tags})
def _send_files_index(self) -> None:
with DbConn() as conn:
TagSet(set(self.params.all_for('needed_tag'))),
bool(self.params.first_for('show_absent')))
files.sort(key=lambda t: t.rel_path)
- self.send_http(bytes(json_dumps([f.as_dict for f in files]),
- encoding='utf8'),
- headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)])
+ self._send_json([f.as_dict for f in files])
def _send_missing_json(self) -> None:
with DbConn() as conn:
missing = [f.digest.b64 for f in VideoFile.get_all(conn)
if f.missing]
- self.send_http(bytes(json_dumps(missing), encoding='utf8'),
- headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)])
+ self._send_json(missing)
def _send_thumbnail(self) -> None:
filename = Path(self.path_toks[2])
return {
'digest': self.digest.b64,
'duration': self.duration(short=True),
+ 'flags': self.flags_as_str_list,
'present': self.present,
'rel_path': str(self.rel_path),
'size': f'{self.size:.2f}',
self._renew_last_update()
return super().save(conn)
+ def test_deletion(self, do_raise: bool) -> bool:
+ """If 'delete' flag set, return True or raise NotFound, else False."""
+ if self.is_flag_set(FlagName('delete')):
+ if do_raise:
+ raise NotFoundException('not showing entry marked as deleted')
+ return True
+ return False
+
+ @classmethod
+ def get_one(cls, conn: DbConn, id_: str | Hash) -> Self:
+ """Extend super by .test_deletion."""
+ file = super().get_one(conn, id_)
+ file.test_deletion(do_raise=True) # pylint: disable=no-member
+ # NB: mypy recognizes file as VideoFile without below assert and
+ # if-isinstance-else, yet less type-smart pylint only does due to the
+ # latter (also the reason for the disable=no-member above; but wouldn't
+ # suffice here, pylint would still identify function's return falsely);
+ # the assert isn't needed by mypy and not enough for pylint, but is
+ # included just so no future code change would trigger the else result.
+ assert isinstance(file, VideoFile)
+ return file if isinstance(file, VideoFile) else cls(None, Path(''))
+
+ @classmethod
+ def get_all(cls, conn: DbConn) -> list[Self]:
+ """Extend super by excluding matches in .test_deletion."""
+ files = super().get_all(conn)
+ return [f for f in files if not f.test_deletion(do_raise=False)]
+
@classmethod
def get_by_yt_id(cls, conn: DbConn, yt_id: YoutubeId) -> Self:
"""Return VideoFile of .yt_id."""
(yt_id,)).fetchone()
if not row:
raise NotFoundException(f'no entry for file to Youtube ID {yt_id}')
- return cls._from_table_row(row)
+ file = cls._from_table_row(row)
+ file.test_deletion(do_raise=False)
+ return file
@classmethod
def get_filtered(cls,
"""Return if file absent despite absence of 'delete' flag."""
return not (self.is_flag_set(FlagName('delete')) or self.present)
+ @property
+ def flags_as_str_list(self) -> list[str]:
+ """Return all set flags."""
+ return [str(k) for k in FILE_FLAGS if self.is_flag_set(k)]
+
def set_flags(self, flags: set[FlagsInt]) -> None:
"""Set .flags to bitwise OR of FlagsInt in flags."""
self.flags = FlagsInt(0)