From: Christian Heller Date: Fri, 2 Jan 2026 00:12:08 +0000 (+0100) Subject: Move JS code into separate files, and over-all refactoring, minor other code improvem... X-Git-Url: https://plomlompom.com/repos/booking/%7B%7Bdb.prefix%7D%7D/%7B%7Bprefix%7D%7D/%7B%7B%20web_path%20%7D%7D/day?a=commitdiff_plain;ds=sidebyside;p=ytplom Move JS code into separate files, and over-all refactoring, minor other code improvements. --- diff --git a/src/templates/_base.js b/src/templates/_base.js new file mode 100644 index 0000000..0aa2c25 --- /dev/null +++ b/src/templates/_base.js @@ -0,0 +1,533 @@ +/* +global +EventSource +clearInterval +console +document +fetch +setInterval +setTimeout +window +*/ +/* +eslint +"array-element-newline": [ + "error", + "consistent", + "multiline" +], +"capitalized-comments": [ + "error", + "never" +], +"function-paren-newline": "off", +"max-lines": [ + "error", + {"max": 450, "skipBlankLines": true, "skipComments": true} +], +"max-lines-per-function": [ + "error", + 84 +], +"max-params": [ + "error", + 5 +], +"max-statements": [ + "error", + 22 +], +"multiline-ternary": [ + "error", + "always-multiline" +], +"newline-after-var": "off", +"newline-before-return": "off", +"no-console": [ + "error", + { "allow": ["dir", "log", "warn", "error"] } +], +"no-extra-parens": "off", +"no-nested-ternary": "off", +"no-ternary": "off", +"padded-blocks": [ + "error", + "never" +], +"one-var": [ + "error", + "consecutive" +], +*/ + +const + CLS_REVERSE = "reverse", + CLS_SORTABLE_ROW = "sortable_row", + CLS_SORTING = "sorting", + DEC_BASE = 10, + DUR_MS_IN_S = 1000, + DUR_S_IN_M = 60, + DUR_S_MIN = 0, + DUR_S_TIMESTAMP_INC = 1, + DUR_S_TO_RETRY = 5, + FACTOR_IDENTICAL = 1, + FACTOR_INVERT = -1, + ID_PLAYER_CTLS = "player_controls", + ID_SORTABLE_TABLE = "sortable_table", + IS_GT = 1, + IS_LTE = -1, + PATH_EVENTS = "/events", + PATH_PLAYER = "/player", + STATUS_CODE_OK = 200; +export const + SYMBOL_DOWN = "v", + SYMBOL_UP = "^"; +export const + BUTTONS_UP_DOWN = [ + [SYMBOL_UP, "up"], + [SYMBOL_DOWN, "down"] + ], + CMD_ADD_NEXT = "inject", + CMD_ADD_PLAY = "injectplay", + CMD_RM = "rm", + IDX_PATH_ID = 2, + IDX_START = 0, + LABEL_ADD_NEXT = "add as next", + LABEL_ADD_PLAY = "add and play", + LEN_EMPTY = 0, + PARAM_TAG_NEEDED = "needed_tag", + PATH_DOWNLOADS_JSON = "/downloads.json", + PATH_FILES = "/files", + PATH_FILES_JSON = "/files.json", + PATH_PREFIX_DOWNLOAD = "/download/", + PATH_PREFIX_FILE = "/file/", + PATH_PREFIX_FILE_JSON = "/file.json/", + PATH_PREFIX_YT_RESULT = "/yt_result/", + PREFIX_CLICKABLE = "clickable", + PREFIX_MINUS = "-", + SYMBOL_RM = "x", + eventHandlers = { + "ping" ( + ) { + // dummy to keep connection alive + } + }; +let + eventsParams = "", + eventsStream = null, + playerDiv = null, + timestampInterval = null; + +const connectEvents = ( +) => { + eventsStream = new EventSource(`${PATH_EVENTS}?${eventsParams}`); + eventsStream.onmessage = ( + event + ) => { + const payload = JSON.parse(event.data); + Object.keys(payload).forEach( + (key) => eventHandlers[key](payload[key])); + }; + eventsStream.onerror = ( + error + ) => { + const whileConnecting = eventsStream.readyState === + eventsStream.CONNECTING; + console.log( + `Error on ${PATH_EVENTS} connection:`, + error + ); + eventsStream.close(); + if (whileConnecting) { + console.log("Error seemed connection-related, trying reconnect."); + setTimeout( + connectEvents, + DUR_S_TO_RETRY * DUR_MS_IN_S + ); + } else { + console.log( + "Error does not seem connection-related, therefore aborting." + ); + } + }; +}; + +export const subscribeEvents = ( + params = "" +) => { + eventsParams = params; + window.addEventListener( + "load", + connectEvents + ); +}; + +export const addChildTo = ( + tag, + parent, + attrs = {} +) => { + const el = document.createElement(tag); + parent.appendChild(el); + for (const [key, value] of Object.entries(attrs)) { + el[key] = value; + } + return el; +}; + +export const addATo = ( + parent, + textContent, + href +) => addChildTo( + "a", + parent, + { + href, + textContent + } +); + +export const wrappedFetch = async ( + target, + verbose, + fetchKwargs +) => { + if (verbose) { + console.log( + `Fetching ${target}, kwargs:`, + fetchKwargs + ); + } + try { + const response = await fetch( + target, + fetchKwargs + ); + if (response.status !== STATUS_CODE_OK) { + const err = Error( + `Unexpected response status: ${response.status}`); + err.response = response; + throw err; + } + return response; + } catch (error) { + console.log(`Error on sending to ${target}: ${error.message}`); + console.dir(error); + throw error; + } +}; + +export const wrappedPost = async ( + target, + body, + verbose = false +) => { + const response = await wrappedFetch( + target, + verbose, + { + "body": JSON.stringify(body), + "headers": {"Content-Type": "application/json"}, + "method": "POST" + } + ); + return response; +}; + +export const wrappedCommand = ( + target, + command +) => wrappedPost( + target, + {"command": [command]} +); + +export const playerCommand = (command) => { + wrappedCommand( + PATH_PLAYER, + command + ); +}; + +export const addButtonTo = ( + parent, + label, + disabled, + onclick +) => addChildTo( + "button", + parent, + { + disabled, + onclick, + "textContent": label + } +); + +export const addPlayerBtnTo = ( + parent, + label, + command, + disabled = false +) => addButtonTo( + parent, + label, + disabled, + () => playerCommand(command) +); + +export const addTextTo = ( + parent, + text +) => { + const node = document.createTextNode(text); + parent.appendChild(node); + return node; +}; + +export const addTdTo = ( + parent, + attrs = {} +) => addChildTo( + "td", + parent, + attrs +); + +export const addATdTo = ( + parent, + text, + href +) => addATo( + addTdTo(parent), + text, + href +); + +export const addTrTo = ( + parent +) => addChildTo( + "tr", + parent +); + +export const addTagLinksTo = ( + parent, + tags +) => { + tags.forEach( + ( + tag + ) => { + addATo( + parent, + tag, + `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}` + ); + addTextTo( + parent, + " " + ); + } + ); +}; + +eventHandlers.player = ( + data +) => { + const + addTextToDiv = ( + text + ) => addTextTo( + playerDiv, + text + ); + const // eslint-disable-line one-var -- to provide addTextToDiv above + addPlayerBtnToDiv = ( + label, + command + ) => addPlayerBtnTo( + playerDiv, + label, + command, + !data.can_play + ), + addSeparator = ( + ) => addTextToDiv( + " · " + ); + playerDiv.innerHTML = ""; + addPlayerBtnToDiv( + "prev", + "prev" + ); + addPlayerBtnToDiv( + "next", + "next" + ); + addPlayerBtnToDiv( + data.is_playing ? "pause" : "play", + "play" + ); + addSeparator(); + addTextToDiv( + data.is_running ? (data.is_playing ? "playing" : "paused") : "stopped" + ); + if (data.title_digest) { + const formatSeconds = (totalSeconds) => { + if (totalSeconds < DUR_S_MIN) { + return "?"; + } + const + minutes = Math.floor(totalSeconds / DUR_S_IN_M), + seconds = totalSeconds % DUR_S_IN_M; + return `${minutes}:${seconds < DEC_BASE ? "0" : ""}${seconds}`; + }; + addTextToDiv(" ("); + const timestampSpan = addChildTo( + "span", + playerDiv + ); + clearInterval(timestampInterval); + let {timestamp} = data; + timestampSpan.textContent = formatSeconds(timestamp); + if (data.is_playing) { + timestampInterval = setInterval( + () => { + timestamp += DUR_S_TIMESTAMP_INC; + timestampSpan.textContent = formatSeconds(timestamp); + }, + DUR_MS_IN_S / data.speed + ); + } + addTextToDiv(`/${formatSeconds(data.duration)}): `); + addATo( + playerDiv, + data.title, + `${PATH_PREFIX_FILE}${data.title_digest}` + ); + addSeparator(); + addTagLinksTo( + playerDiv, + data.title_tags + ); + } +}; + +export const sortableTableSetup = { + "itemsForSortableTable": null, + "populateListItemRow": null, + "sortCols": null, + "sortKey": null +}; + +export const drawTable = ( + idTable, + clsTableRow, + items, + populateRow, + emptyFirst = true +) => { + const table = document.getElementById(idTable); + if (emptyFirst) { + Array.from( + document.getElementsByClassName(clsTableRow) + ).forEach((row) => row.remove()); + } + items.forEach((item, idx, arr) => { + const tr = addTrTo(table); + tr.classList.add(clsTableRow); + populateRow( + tr, + item, + idx, + arr + ); + }); +}; + +export const drawSortableTable = ( +) => { + sortableTableSetup.itemsForSortableTable.sort( + ( + cmpA, + cmpB + ) => { + let + inverter = FACTOR_IDENTICAL, + {sortKey} = sortableTableSetup; + if (sortKey.startsWith(PREFIX_MINUS)) { + inverter = FACTOR_INVERT; + sortKey = sortKey.substring(PREFIX_MINUS.length); + } + return inverter * (cmpA[sortKey] > cmpB[sortKey] ? IS_GT : IS_LTE); + } + ); + drawTable( + ID_SORTABLE_TABLE, + CLS_SORTABLE_ROW, + sortableTableSetup.itemsForSortableTable, + sortableTableSetup.populateListItemRow + ); +}; + +export const assignOnclicks = (items, onclick) => { + items.forEach((item) => { + const el = document.getElementById(`${PREFIX_CLICKABLE}:${item}`); + el.onclick = ( + ) => onclick( + el, + item + ); + }); +}; + +window.addEventListener( + "beforeunload", + () => { + if (eventsStream) { + eventsStream.close(); + } + } +); + +window.addEventListener( + "load", + () => { + playerDiv = document.getElementById(ID_PLAYER_CTLS); + + if (sortableTableSetup.populateListItemRow) { + drawSortableTable(); + assignOnclicks( + sortableTableSetup.sortCols, + ( + button, + colName + ) => { + Array.from( + document.getElementsByClassName(CLS_SORTING) + ).forEach( + ( + btnIdx + ) => { + if (btnIdx !== button) { + btnIdx.classList.remove(CLS_SORTING); + btnIdx.classList.remove(CLS_REVERSE); + } else if (btnIdx.classList.contains(CLS_SORTING)) { + btnIdx.classList.toggle(CLS_REVERSE); + } + } + ); + button.classList.add(CLS_SORTING); + const prefix = button.classList.contains(CLS_REVERSE) + ? "" + : PREFIX_MINUS; + sortableTableSetup.sortKey = `${prefix}${colName}`; + drawSortableTable(); + } + ); + } + } +); + diff --git a/src/templates/_base.tmpl b/src/templates/_base.tmpl index 1845b4a..1827aeb 100644 --- a/src/templates/_base.tmpl +++ b/src/templates/_base.tmpl @@ -1,173 +1,51 @@ -{% import '_macros.tmpl' as macros %} +{% macro section_link(pagename, target, display_name = false) %} +{% if target != pagename %}{% endif %} +{% if display_name %}{{display_name}}{% else %}{{target}}{% endif %} +{% if target != pagename %}{% endif %} +{% endmacro %} + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if allow_edit %} + + + + +{% endif %} + +
path:{{file.rel_path}}
present: +
YouTube ID:{{file.yt_id}} +
duration{{file.duration()}}
tags + +{% if allow_edit %} + + + + +{% endif %} +
+ + + +
+
options + + + + + +
+ do sync
+
+ +
+ +
+
+
+{% endblock %} diff --git a/src/templates/file.js b/src/templates/file.js new file mode 100644 index 0000000..75e1773 --- /dev/null +++ b/src/templates/file.js @@ -0,0 +1,215 @@ +/* global +document, +window +*/ +/* +eslint +"function-paren-newline": "off", +"no-extra-parens": "off", +"max-lines-per-function": [ + "error", + 73 +], +"max-statements": [ + "error", + 16 +], +"newline-after-var": "off", +"padded-blocks": [ + "error", + "never" +], +*/ +import { + CMD_ADD_NEXT, + CMD_ADD_PLAY, + IDX_PATH_ID, + LABEL_ADD_NEXT, + LABEL_ADD_PLAY, + LEN_EMPTY, + PATH_FILES, + PATH_PREFIX_FILE, + PATH_PREFIX_FILE_JSON, + PREFIX_CLICKABLE, + addATo, + addChildTo, + addPlayerBtnTo, + addTdTo, + addTextTo, + assignOnclicks, + drawTable, + subscribeEvents, + wrappedFetch, + wrappedPost +} from "./_base.js"; + +const + CLS_TAG_CHECKBOX = "tag_checkboxes", + CLS_TAG_LISTED = "listed_tags", + EDIT_BUTTONS = [ + "add_tag_button", + "sync_checkbox", + "unlink_button" + ], + FLAG_NO_SYNC = "do not sync", + IDX_TAG_CHECKBOX_TD = 0, + IDX_TAG_CHECKBOX_TD_INPUT = 0, + IDX_TAG_LINK_TD = 1, + IDX_TAG_LINK_TD_A = 0, + ID_BTN_ADD_TAG = `${PREFIX_CLICKABLE}:add_tag_button`, + ID_BTN_SYNC = `${PREFIX_CLICKABLE}:sync_checkbox`, + ID_BTN_UNLINK = `${PREFIX_CLICKABLE}:unlink_button`, + ID_PRESENCE = "presence", + ID_TAGS_TABLE = "tags_table", + ID_TAGS_UNUSED = "unused_tags", + ID_TAG_ADDED = "added_tag", + LABEL_ABSENT = "no", + LABEL_PRESENT = "yes", + PARAM_TAG_NEEDED = "needed_tag", + PATH_FILE_JSON = ( + PATH_PREFIX_FILE_JSON + + window.location.pathname.split("/")[IDX_PATH_ID] + ); + +let + allowEdit = null; + +const updateFileData = async () => { // eslint-disable-line one-var + const + fetched = await wrappedFetch(PATH_FILE_JSON), + fileData = await fetched.json(), + tdPresent = document.getElementById(ID_PRESENCE); + drawTable( + ID_TAGS_TABLE, + CLS_TAG_LISTED, + fileData.tags_showable, + ( + tr, + tag + ) => { + if (allowEdit) { + const tdCheckbox = addTdTo(tr); + tdCheckbox.classList.add(CLS_TAG_CHECKBOX); + addChildTo( + "input", + tdCheckbox, + { + "checked": true, + /* eslint-disable-next-line no-use-before-define */ + "onclick": sendUpdate, + "type": "checkbox" + } + ); + } + addATo( + addTdTo(tr), + tag, + `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}` + ); + } + ); + if (allowEdit) { + const dataList = document.getElementById(ID_TAGS_UNUSED); + dataList.innerHTML = ""; + document.getElementById(ID_BTN_SYNC).checked = + !fileData.flags.includes(FLAG_NO_SYNC); + document.getElementById(ID_TAG_ADDED).value = ""; + fileData.unused_tags.forEach( + (tag) => addChildTo( + "option", + dataList, + {"textContent": tag} + ) + ); + } + tdPresent.innerHTML = ""; + if (fileData.present) { + addATo( + tdPresent, + LABEL_PRESENT, + `${PATH_PREFIX_FILE}${fileData.yt_id}` + ); + addTextTo( + tdPresent, + " " + ); + addPlayerBtnTo( + tdPresent, + LABEL_ADD_NEXT, + `${CMD_ADD_NEXT}_${fileData.digest}` + ); + addPlayerBtnTo( + tdPresent, + LABEL_ADD_PLAY, + `${CMD_ADD_PLAY}_${fileData.digest}` + ); + } else { + tdPresent.textContent = LABEL_ABSENT; + } +}; + +const sendUpdate = async ( // eslint-disable-line one-var + button +) => { + const + addedTag = document.getElementById(ID_TAG_ADDED).value, + flags = [], + tags = []; + Array.from(document.getElementsByClassName(CLS_TAG_LISTED)).forEach( + (tr) => { + if ( + tr.children[ + IDX_TAG_CHECKBOX_TD + ].children[ + IDX_TAG_CHECKBOX_TD_INPUT + ].checked + ) { + tags.push( + tr.children[ + IDX_TAG_LINK_TD + ].children[ + IDX_TAG_LINK_TD_A + ].textContent + ); + } + } + ); + if ( + addedTag.length > LEN_EMPTY && + button === document.getElementById(ID_BTN_ADD_TAG) + ) { + tags.push(addedTag); + } + if (!document.getElementById(ID_BTN_SYNC).checked) { + flags.push(FLAG_NO_SYNC); + } + await wrappedPost( + PATH_FILE_JSON, + { + "delete_locally": ( + button === + document.getElementById(ID_BTN_UNLINK) + ), + flags, + tags + } + ); + updateFileData(); +}; + +window.addEventListener( + "load", + () => { + allowEdit = document.getElementById(ID_TAG_ADDED) !== null; + updateFileData(); + if (allowEdit) { + assignOnclicks( + EDIT_BUTTONS, + (btn) => sendUpdate(btn) + ); + } + } +); + +subscribeEvents(); + diff --git a/src/templates/file_data.tmpl b/src/templates/file_data.tmpl deleted file mode 100644 index e1247a3..0000000 --- a/src/templates/file_data.tmpl +++ /dev/null @@ -1,141 +0,0 @@ -{% 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()); - Array.from(document.getElementsByClassName("listed_tags")).forEach((row) => row.remove()); - file_data.tags_showable.forEach((tag) => { - const tr = add_child_to("tr", document.getElementById("tags_table")); - tr.classList.add("listed_tags"); - {% if allow_edit %} - td_checkbox = add_child_to("td", tr); - td_checkbox.classList.add("tag_checkboxes"); - add_child_to("input", td_checkbox, {type: "checkbox", checked: true, onclick: send_update}); - {% endif %} - add_a_to(add_child_to("td", add_child_to("td", tr)), tag, - `${PATH_FILES}?needed_tag=${encodeURIComponent(tag)}`); - }); - {% if allow_edit %} - document.getElementById("sync_checkbox").checked = ! file_data.flags.includes("do not sync"); - document.getElementById("added_tag").value = ""; - const datalist = document.getElementById("unused_tags"); - datalist.innerHTML = ""; - file_data.unused_tags.forEach((tag) => { add_child_to("option", datalist, {textContent: tag}); }); - {% endif %} - td_present = document.getElementById("presence"); - td_present.innerHTML = ""; - if (file_data.present) { - add_a_to(td_present, "yes", "/{{page_names.download}}/{{file.yt_id}}"); - add_text_to(td_present, " "); - add_player_btn_to(td_present, 'add as next', `inject_${file_data.digest}`); - add_player_btn_to(td_present, 'add and play', `injectplay_${file_data.digest}`); - } 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 { text-align: right} -td.dangerous > form > input[type=submit], td.dangerous > button { background-color: black; color: red; } -{% endblock %} - - -{% block body %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - -{% if allow_edit %} - - - - -{% endif %} - -
path:{{file.rel_path}}
present: -
YouTube ID:{{file.yt_id}} -
duration{{file.duration()}}
tags - -{% if allow_edit %} - - - - -{% endif %} -
- - - -
-
options - - - - - -
- do sync
-
- -
- -
-
-
-{% endblock %} diff --git a/src/templates/files.html b/src/templates/files.html new file mode 100644 index 0000000..183301a --- /dev/null +++ b/src/templates/files.html @@ -0,0 +1,33 @@ +{% extends '_base.tmpl' %} +{% import '_macros.tmpl' as macros %} + + +{% block css %} +{{ macros.css_sortable_table() }} +{% endblock %} + + +{% block body %} +filename pattern:
+{% if allow_show_absent %} +show absent:
+{% endif %} +needed tags:
+ +
+ +

+known files (shown: ?): + +

+ + + + + + + + + +
actionstags
+{% endblock %} diff --git a/src/templates/files.js b/src/templates/files.js new file mode 100644 index 0000000..7629481 --- /dev/null +++ b/src/templates/files.js @@ -0,0 +1,215 @@ +/* global +URLSearchParams, +console, +document, +window, +*/ +/* +eslint +"function-paren-newline": "off", +"newline-after-var": "off", +no-console: [ + "error", + { "allow": ["dir", "log", "warn", "error"] } +], +"max-statements": [ + "error", + 13 +], +"padded-blocks": [ + "error", + "never" +], +*/ +import { + CMD_ADD_NEXT, + CMD_ADD_PLAY, + IDX_START, + LABEL_ADD_NEXT, + LABEL_ADD_PLAY, + LEN_EMPTY, + PARAM_TAG_NEEDED, + PATH_FILES_JSON, + PATH_PREFIX_FILE, + PREFIX_MINUS, + SYMBOL_RM, + addATdTo, + addButtonTo, + addChildTo, + addPlayerBtnTo, + addTagLinksTo, + addTdTo, + addTextTo, + drawSortableTable, + playerCommand, + sortableTableSetup, + subscribeEvents, + wrappedFetch +} from "./_base.js"; + +const + COLNAME_DURATION = "duration", + COLNAME_LAST_UPDATES = "last_updates", + COLNAME_N_TAGS = "n_tags", + COLNAME_REL_PATH = "rel_path", + COLNAME_SIZE = "size", + ID_ADD_ALL = "inject_all", + ID_FILES_COUNT = "files_count", + ID_INPUT_FILTER_PATH = "input_filter_path", + ID_INPUT_SHOW_ABSENT = "input_show_absent", + ID_TAGS = "tags", + ID_TAGS_SELECT = "tags_select", + LABEL_ADD = "add", + PARAM_FILTER_PATH = "filter_path", + PARAM_SHOW_ABSENT = "show_absent", + TAGS_SHOWABLE = {{showable_tags|tojson|safe}}; + +let + inputFilterPath = null, + inputShowAbsent = null, + neededTags = new URLSearchParams( + window.location.search + ).getAll(PARAM_TAG_NEEDED), + tagsSelect = null, + tagsSpan = null; +sortableTableSetup.itemsForSortableTable = []; +sortableTableSetup.sortCols = [ + COLNAME_SIZE, + COLNAME_DURATION, + COLNAME_N_TAGS, + COLNAME_REL_PATH, + `${PREFIX_MINUS}${COLNAME_LAST_UPDATES}` +]; +sortableTableSetup.sortKey = COLNAME_REL_PATH; +sortableTableSetup.populateListItemRow = ( + tr, + file +) => { + addTdTo( + tr, + {"textContent": file.size} + ); + addTdTo( + tr, + {"textContent": file.duration} + ); + const tdInject = addTdTo(tr); + addPlayerBtnTo( + tdInject, + LABEL_ADD_NEXT, + `${CMD_ADD_NEXT}_${file.digest}`, + !file.present + ); + addPlayerBtnTo( + tdInject, + LABEL_ADD_PLAY, + `${CMD_ADD_PLAY}_${file.digest}`, + !file.present + ); + addTagLinksTo( + addTdTo(tr), + file.tags_showable + ); + addATdTo( + tr, + file.rel_path, + `${PATH_PREFIX_FILE}${file.digest}` + ); + addTdTo( + tr, + {"textContent": file.last_update} + ); +}; + +const updateFilesList = async ( // eslint-disable-line one-var +) => { + const filterPath = encodeURIComponent(inputFilterPath.value); + let target = `${PATH_FILES_JSON}?${PARAM_FILTER_PATH}=${filterPath}`; + if (inputShowAbsent && inputShowAbsent.checked) { + target = `${target}&${PARAM_SHOW_ABSENT}=1`; + } + neededTags.forEach((tag) => { + target = `${target}&${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}`; + }); + try { + const fetched = await wrappedFetch(target); + sortableTableSetup.itemsForSortableTable = await fetched.json(); + document.getElementById(ID_FILES_COUNT).textContent = + `${sortableTableSetup.itemsForSortableTable.length}`; + drawSortableTable(); + } catch (error) { + console.log(`Error updating ${target} files list: ${error.message}`); + console.dir(error); + throw error; + } +}; + + +const updateFilterInputs = ( // eslint-disable-line one-var +) => { + tagsSpan.innerHTML = ""; + while (tagsSelect.options.length > LEN_EMPTY) { + tagsSelect.remove(IDX_START); + } + addChildTo( + "option", + tagsSelect, + {"textContent": LABEL_ADD} + ); + TAGS_SHOWABLE.forEach((tag) => { + if (!neededTags.includes(tag)) { + addChildTo( + "option", + tagsSelect, + {"textContent": tag} + ); + } + }); + neededTags.forEach((chosenTag) => { + addTextTo( + tagsSpan, + ` ${chosenTag} ` + ); + addButtonTo( + tagsSpan, + SYMBOL_RM, + false, + () => { + neededTags = neededTags.filter((tag) => tag !== chosenTag); + updateFilterInputs(); + } + ); + }); + updateFilesList(); +}; + +window.addEventListener( + "load", + () => { + inputFilterPath = document.getElementById(ID_INPUT_FILTER_PATH); + inputFilterPath.oninput = updateFilesList; + tagsSelect = document.getElementById(ID_TAGS_SELECT); + tagsSpan = document.getElementById(ID_TAGS); + updateFilterInputs(); + inputShowAbsent = document.getElementById(ID_INPUT_SHOW_ABSENT); + if (inputShowAbsent) { + inputShowAbsent.onclick = updateFilesList; + } + document.getElementById(ID_ADD_ALL).onclick = () => { + sortableTableSetup.itemsForSortableTable.forEach( + (file) => playerCommand(`${CMD_ADD_NEXT}_${file.digest}`) + ); + }; + tagsSelect.onchange = () => { + if (tagsSelect.selectedIndex > IDX_START) { + neededTags.push( + document.getElementById(ID_TAGS_SELECT).value + ); + } + updateFilterInputs(); + }; + } +); + +subscribeEvents(); + diff --git a/src/templates/files.tmpl b/src/templates/files.tmpl deleted file mode 100644 index 6091cbd..0000000 --- a/src/templates/files.tmpl +++ /dev/null @@ -1,108 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block script %} -const PATH_FILES_JSON = "/{{page_names.files_json}}"; - -const all_tags = {{showable_tags|tojson|safe}}; -var needed_tags = {{needed_tags|tojson|safe}}; - -function select_tag() { - if (tags_select.selectedIndex < 1) { - return; - } - const chosen_tag = document.getElementById('tags_select').value; - needed_tags.push(chosen_tag); - update_filter_inputs(); -} - -function update_filter_inputs() { - const tags_select = document.getElementById('tags_select'); - while (tags_select.options.length > 0) { - tags_select.remove(0); - } - add_child_to('option', tags_select, {textContent: 'add'}); - all_tags.forEach((tag) => { - if (needed_tags.includes(tag)) { - return; - } - add_child_to('option', tags_select, {textContent: tag}); - }); - const tags_div = document.getElementById("tags"); - tags_div.innerHTML = ''; - needed_tags.forEach((chosen_tag) => { - const tag_text_node = add_text_to(tags_div, ` ${chosen_tag} `); - add_child_to('button', tags_div, { - textContent: 'x', - onclick: function() { - tag_text_node.remove(); - btn_del.remove(); - needed_tags = needed_tags.filter(tag => tag !== chosen_tag); - update_filter_inputs(); - } - }); - }); - update_files_list(); -} - -async function update_files_list() { - const filter_path = encodeURIComponent(document.getElementById("input_filter_path").value); - let target = `${PATH_FILES_JSON}?filter_path=${filter_path}`; - {% if allow_show_absent %} - if (document.getElementById("input_show_absent").checked) { target = `${target}&show_absent=1`; } - {% endif %} - needed_tags.forEach((tag) => target = `${target}&needed_tag=${encodeURIComponent(tag)}`); - items_for_sortable_table = await wrapped_fetch(target).then((response) => response.json()); - document.getElementById("files_count").textContent = `${items_for_sortable_table.length}`; - draw_sortable_table(); -} - -function inject_all() { - items_for_sortable_table.forEach((file) => { player_command(`inject_${file.digest}`) }); -} - -{{ macros.js_manage_sortable_table("rel_path") }} -var items_for_sortable_table = []; -function populate_list_item_row(tr, file) { - add_child_to('td', tr, {textContent: file.size}); - add_child_to('td', tr, {textContent: file.duration}); - const td_inject = add_child_to('td', tr); - add_player_btn_to(td_inject, 'add as next', `inject_${file.digest}`, !file.present) - add_player_btn_to(td_inject, 'add as play', `injectplay_${file.digest}`, !file.present) - add_tag_links_to(add_child_to('td', tr), file.tags_showable); - add_a_to(add_child_to('td', tr), file.rel_path, `${PATH_PREFIX_FILE}${file.digest}`); - add_child_to('td', tr, {textContent: file.last_update}); -} -window.addEventListener('load', update_filter_inputs); -{% endblock %} - - -{% block css %} -{{ macros.css_sortable_table() }} -{% endblock %} - - -{% block body %} -filename pattern:
-{% if allow_show_absent %} -show absent:
-{% endif %} -needed tags:
- -
- -

-known files (shown: ?): - -

- - - - - - - - - -
actionstags
-{% endblock %} diff --git a/src/templates/playlist.html b/src/templates/playlist.html new file mode 100644 index 0000000..3f55331 --- /dev/null +++ b/src/templates/playlist.html @@ -0,0 +1,21 @@ +{% extends '_base.tmpl' %} + + +{% block css %} +html { + scroll-padding-top: 7.5em; /* so anchor jumps pad off sticky header */ +} +td.entry_control { + width: 6em; +} +{% endblock %} + + +{% block body %} +jump to playing + + +
+ +
+{% endblock %} diff --git a/src/templates/playlist.js b/src/templates/playlist.js new file mode 100644 index 0000000..890e0d4 --- /dev/null +++ b/src/templates/playlist.js @@ -0,0 +1,140 @@ +/* global +window +*/ +/* +eslint +"capitalized-comments": [ + "error", + "never" +], +"function-paren-newline": "off", +"line-comment-position": [ + "error", + { "position": "beside" } +], +"max-lines-per-function": [ + "error", + 51 +], +"max-statements": [ + "error", + 12 +], +"newline-after-var": "off", +"no-inline-comments": "off", +"no-multi-spaces": [ + "error", + { "ignoreEOLComments": true } +], +"padded-blocks": [ + "error", + "never" +], + */ +import { + BUTTONS_UP_DOWN, + CMD_RM, + PATH_PREFIX_FILE, + SYMBOL_RM, + addATdTo, + addATo, + addPlayerBtnTo, + addTdTo, + assignOnclicks, + drawTable, + eventHandlers, + playerCommand, + subscribeEvents +} from "./_base.js"; + +const + BUTTONS_ENTRY = [ + [ + ">", + "jump" + ] + ].concat(BUTTONS_UP_DOWN).concat([ + [ + SYMBOL_RM, + CMD_RM + ] + ]), + BUTTONS_PLAYLIST = [ + "empty", + "rebuild" + ], + CLS_ENTRY_CTL = "entry_control", + CLS_PLAYLIST_ROW = "playlist_row", + ID_TABLE = `${CLS_PLAYLIST_ROW}s`, + PARAM_PLAYLIST = "playlist", + TOK_ANCHOR = "#", + TOK_PLAYING = "playing"; + +let + firstLoad = true; + +eventHandlers.playlist = ( + update +) => { // update playlist + drawTable( + ID_TABLE, + CLS_PLAYLIST_ROW, + update.playlist_files, + ( + tr, + file, + idx + ) => { + addATo( + addTdTo( + tr, + {"id": `${idx}`} + ), + TOK_ANCHOR, + `#${idx}` + ); + const tdEntryControl = addTdTo(tr); + tdEntryControl.classList.add(CLS_ENTRY_CTL); + addATdTo( + tr, + file.rel_path, + `${PATH_PREFIX_FILE}${file.digest}` + ); + if (idx === update.idx) { // currently playing + tdEntryControl.textContent = TOK_PLAYING; + tdEntryControl.id = TOK_PLAYING; + if (firstLoad) { // replace anchor jump to #playing + firstLoad = false; // (initially un-built, won"t work) + tdEntryControl.scrollIntoView({"block": "center"}); + } + } else { // only non-playing items get playlist manip. buttons + for ( + const [ + symbol, + prefix + ] of BUTTONS_ENTRY + ) { + addPlayerBtnTo( + tdEntryControl, + symbol, + `${prefix}_${idx}` + ); + } + } + } + ); +}; + +window.addEventListener( + "load", + () => assignOnclicks( + BUTTONS_PLAYLIST, + ( + ignore, + command + ) => playerCommand(command) + ) +); + +subscribeEvents(`${PARAM_PLAYLIST}=1`); + diff --git a/src/templates/playlist.tmpl b/src/templates/playlist.tmpl deleted file mode 100644 index f956ea2..0000000 --- a/src/templates/playlist.tmpl +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block css %} -html { scroll-padding-top: 7.5em; } /* so anchor jumps pad off sticky header */ -td.entry_control { width: 6em; } -{% endblock %} - - -{% block script %} -const CLS_PLAYLIST_ROW = 'playlist_row'; -events_params += 'playlist=1'; -var first_load = true; - -event_handlers.playlist = function(data) { // update playlist - const table = document.getElementById('playlist_rows'); - var old_rows = document.getElementsByClassName(CLS_PLAYLIST_ROW); - while (old_rows[0]) { old_rows[0].remove(); } - for (let i = 0; i < data.playlist_files.length; i++) { - const file = data.playlist_files[i]; - const tr = add_child_to('tr', table); - tr.classList.add(CLS_PLAYLIST_ROW); - add_a_to(add_child_to('td', tr, {id: `${i}`}), '#', `#${i}`); - const td_entry_control = add_child_to('td', tr); - td_entry_control.classList.add('entry_control'); - if (i == data.idx) { - td_entry_control.textContent = 'playing'; - td_entry_control.id = 'playing'; - if (first_load) { // to replace anchor jump to #playing, which on first - first_load = false; // load will not work yet since element not built yet - td_entry_control.scrollIntoView({block: 'center'}); - } - } else { - for (const [symbol, prefix] of [['>', 'jump'], - ['^', 'up'], - ['v', 'down'], - ['x', 'rm']]) { - add_player_btn_to(td_entry_control, symbol, `${prefix}_${i}`); - } - } - add_a_to(add_child_to('td', tr), file.rel_path, `${PATH_PREFIX_FILE}${file.digest}`); - } -}; -{% endblock %} - - -{% block body %} -jump to playing - - -
- - -
-{% endblock %} diff --git a/src/templates/tags.html b/src/templates/tags.html new file mode 100644 index 0000000..94d581e --- /dev/null +++ b/src/templates/tags.html @@ -0,0 +1,25 @@ +{% extends '_base.tmpl' %} +{% import '_macros.tmpl' as macros %} + + +{% block css %} +{{ macros.css_sortable_table() }} +#sortable_table { width: auto; } +{% endblock %} + + +{% block body %} + + + + + + + +
+ + + +
+ +{% endblock %} diff --git a/src/templates/tags.js b/src/templates/tags.js new file mode 100644 index 0000000..b230ede --- /dev/null +++ b/src/templates/tags.js @@ -0,0 +1,47 @@ +/* +eslint +"capitalized-comments": [ + "error", + "never" +], +"padded-blocks": [ + "error", + "never" +], + */ +import { + PARAM_TAG_NEEDED, + PATH_FILES, + addATdTo, + addTdTo, + sortableTableSetup, + subscribeEvents +} from "./_base.js"; + +const + COLNAME_NAME = "name", + COLNAME_NUMBER = "number"; + +sortableTableSetup.itemsForSortableTable = {{tags|tojson|safe}}; +sortableTableSetup.sortCols = [ + COLNAME_NAME, + COLNAME_NUMBER +]; +sortableTableSetup.sortKey = COLNAME_NAME; +sortableTableSetup.populateListItemRow = ( + tr, + tag +) => { + addATdTo( + tr, + tag.name, + `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag.name)}` + ); + addTdTo( + tr, + {"textContent": tag.number} + ); +}; + +subscribeEvents(); + diff --git a/src/templates/tags.tmpl b/src/templates/tags.tmpl deleted file mode 100644 index aae3eab..0000000 --- a/src/templates/tags.tmpl +++ /dev/null @@ -1,32 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block script %} -{{ macros.js_manage_sortable_table("name") }} -var items_for_sortable_table = {{tags|tojson|safe}}; -function populate_list_item_row(tr, tag) { - add_a_to(add_child_to('td', tr), tag.name, - `${PATH_FILES}?needed_tag=${encodeURIComponent(tag.name)}`); - add_child_to('td', tr, {textContent: tag.number}); -} -window.addEventListener('load', draw_sortable_table); -{% endblock %} - - -{% block css %} -{{ macros.css_sortable_table() }} -#sortable_table { width: auto; } -{% endblock %} - - -{% block body %} - - - - - - - -
-{% endblock %} - diff --git a/src/templates/yt_queries.html b/src/templates/yt_queries.html new file mode 100644 index 0000000..bdb7140 --- /dev/null +++ b/src/templates/yt_queries.html @@ -0,0 +1,22 @@ +{% extends '_base.tmpl' %} + + +{% block body %} +

quota: {{quota_count}}/100000

+
+ +
+ + + + + + +{% for query in queries %} + + + + +{% endfor %} +
retrieved atquery
{{query.retrieved_at[:19]}}{{query.text}}
+{% endblock %} diff --git a/src/templates/yt_queries.js b/src/templates/yt_queries.js new file mode 100644 index 0000000..6a2a2ab --- /dev/null +++ b/src/templates/yt_queries.js @@ -0,0 +1,6 @@ +import { + subscribeEvents +} from "./_base.js"; + +subscribeEvents(); + diff --git a/src/templates/yt_queries.tmpl b/src/templates/yt_queries.tmpl deleted file mode 100644 index 1bbc0af..0000000 --- a/src/templates/yt_queries.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block body %} -

quota: {{quota_count}}/100000

-
- -
- - - - - - - -{% for query in queries %} - - - - -{% endfor %} -
retrieved atquery
{{query.retrieved_at[:19]}}{{query.text}}
-{% endblock %} diff --git a/src/templates/yt_query.html b/src/templates/yt_query.html new file mode 100644 index 0000000..4f2eea9 --- /dev/null +++ b/src/templates/yt_query.html @@ -0,0 +1,24 @@ +{% extends '_base.tmpl' %} + + +{% block body %} +

query: {{query}}

+
+ + +{% for video in videos %} + + + + + +{% endfor %} +
+ + +{{video.definition}}
+{{video.duration}} +
+{{video.title}} · {{video.description}} +
+{% endblock %} diff --git a/src/templates/yt_query.js b/src/templates/yt_query.js new file mode 100644 index 0000000..6a2a2ab --- /dev/null +++ b/src/templates/yt_query.js @@ -0,0 +1,6 @@ +import { + subscribeEvents +} from "./_base.js"; + +subscribeEvents(); + diff --git a/src/templates/yt_result.html b/src/templates/yt_result.html new file mode 100644 index 0000000..e0e7e5f --- /dev/null +++ b/src/templates/yt_result.html @@ -0,0 +1,22 @@ +{% extends '_base.tmpl' %} + + +{% block body %} + + + + + + + + + + + + +
title:{{video_data.title}}
thumbnail:
description:{{video_data.description}}
duration:{{video_data.duration}}
definition:{{video_data.definition}}
YouTube ID:{% if youtube_prefix|length > 0 %}{{video_data.id_}}{% else %}{{video_data.id_}}{% endif %}
download:
linked queries: + +
+{% endblock %} diff --git a/src/templates/yt_result.js b/src/templates/yt_result.js new file mode 100644 index 0000000..981aafd --- /dev/null +++ b/src/templates/yt_result.js @@ -0,0 +1,58 @@ +/* global +document, +window +*/ +/* +eslint +"function-paren-newline": "off", +"newline-after-var": "off", +"padded-blocks": [ + "error", + "never" +], +*/ +import { + IDX_PATH_ID, + PATH_PREFIX_DOWNLOAD, + PATH_PREFIX_FILE, + addATo, + addTextTo, + eventHandlers, + subscribeEvents +} from "./_base.js"; + +const + ID_TD_STATUS = "status", + PARAM_DOWNLOAD = "download", + TOK_ABSENT = "absent", + TOK_PRESENT = "present", + TOK_Q_DOWNLOAD = "download?", + ytId = window.location.pathname.split("/")[IDX_PATH_ID]; + +eventHandlers.download = ( + data +) => { + const td = document.getElementById(ID_TD_STATUS); + td.innerHTML = ""; + if (data.status === TOK_ABSENT) { + addATo( + td, + TOK_Q_DOWNLOAD, + `${PATH_PREFIX_DOWNLOAD}${ytId}` + ); + } else if (data.status === TOK_PRESENT) { + addATo( + td, + data.path, + `${PATH_PREFIX_FILE}${data.digest}` + ); + } else { + addTextTo( + td, + `${data.status}` + ); + } +}; + +subscribeEvents(`${PARAM_DOWNLOAD}=${ytId}`); + diff --git a/src/templates/yt_result.tmpl b/src/templates/yt_result.tmpl deleted file mode 100644 index fe68a43..0000000 --- a/src/templates/yt_result.tmpl +++ /dev/null @@ -1,45 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block script %} -events_params += 'download={{video_data.id_}}'; -event_handlers.download = function(data) { - const td = document.getElementById("status"); - td.innerHTML = ""; - if ("absent" == data.status) { - add_a_to( - parent=td, - text="download?", - href="/{{page_names.download}}/{{video_data.id_}}"); - } else if ("present" == data.status) { - add_a_to( - parent=td, - text=data.path, - href="/{{page_names.file}}/" + data.digest); - } else { - add_text_to( - parent=td, - text=`${data.status}`); } -} -{% endblock %} - - -{% block body %} - - - - - - - - - - - - -
title:{{video_data.title}}
thumbnail:
description:{{video_data.description}}
duration:{{video_data.duration}}
definition:{{video_data.definition}}
YouTube ID:{% if youtube_prefix|length > 0 %}{{video_data.id_}}{% else %}{{video_data.id_}}{% endif %}
download:
linked queries: - -
-{% endblock %} diff --git a/src/templates/yt_results.tmpl b/src/templates/yt_results.tmpl deleted file mode 100644 index 2115c2b..0000000 --- a/src/templates/yt_results.tmpl +++ /dev/null @@ -1,24 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% block body %} -

query: {{query}}

-
- - -{% for video in videos %} - - - - - -{% endfor %} -
- - -{{video.definition}}
-{{video.duration}} -
-{{video.title}} · {{video.description}} -
-{% endblock %} diff --git a/src/ytplom/http.py b/src/ytplom/http.py index 2eb7930..f4c3ad9 100644 --- a/src/ytplom/http.py +++ b/src/ytplom/http.py @@ -13,7 +13,8 @@ from plomlib.web import ( PlomHttpHandler, PlomHttpServer, PlomQueryMap, MIME_APP_JSON) from ytplom.db import Hash, DbConn from ytplom.misc import ( - FILE_FLAGS, PATH_THUMBNAILS, YOUTUBE_URL_PREFIX, ensure_expected_dirs, + CMD_DOWN, CMD_RM, CMD_UP, FILE_FLAGS, PATH_THUMBNAILS, YOUTUBE_URL_PREFIX, + ensure_expected_dirs, Config, DownloadsManager, FilterStr, FlagName, Player, QueryId, QueryText, QuotaLog, TagSet, VideoFile, YoutubeId, YoutubeQuery, YoutubeVideo ) @@ -24,40 +25,8 @@ from ytplom.primitives import NotFoundException, PATH_APP_DATA _THUMBNAIL_URL_PREFIX = 'https://i.ytimg.com/vi/' _THUMBNAIL_URL_SUFFIX = '/default.jpg' -# template paths -_PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates') -_NAME_TEMPLATE_DOWNLOADS = Path('downloads.tmpl') -_NAME_TEMPLATE_FILE_DATA = Path('file_data.tmpl') -_NAME_TEMPLATE_FILES = Path('files.tmpl') -_NAME_TEMPLATE_PLAYLIST = Path('playlist.tmpl') -_NAME_TEMPLATE_TAGS = Path('tags.tmpl') -_NAME_TEMPLATE_YT_QUERIES = Path('yt_queries.tmpl') -_NAME_TEMPLATE_YT_RESULT = Path('yt_result.tmpl') -_NAME_TEMPLATE_YT_RESULTS = Path('yt_results.tmpl') - -# page names -PAGE_NAMES: dict[str, str] = { - 'download': 'dl', - 'downloads': 'downloads', - 'downloads_json': 'downloads.json', - 'events': 'events', - 'file': 'file', - 'file_kill': 'file_kill', - 'file_json': 'file.json', - 'files': 'files', - 'files_json': 'files.json', - 'missing': 'missing', - 'player': 'player', - 'playlist': 'playlist', - 'purge': 'purge', - 'tags': 'tags', - 'thumbnails': 'thumbnails', - 'yt_queries': 'yt_queries', - 'yt_query': 'yt_query', - 'yt_result': 'yt_result' -} - # misc +_PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates') _PING_INTERVAL_S = 1 _EVENTS_UPDATE_INTERVAL_S = 0.1 _HEADER_CONTENT_TYPE = 'Content-Type' @@ -111,17 +80,17 @@ class _TaskHandler(PlomHttpHandler): def do_POST(self) -> None: # pylint:disable=invalid-name """Map POST requests to handlers for various paths.""" - if self.pagename == PAGE_NAMES['downloads_json']: + if self.pagename == 'downloads.json': self._receive_downloads() - elif self.pagename == PAGE_NAMES['file_json']: + elif self.pagename == 'file.json': self._update_file() - elif self.pagename == PAGE_NAMES['file_kill']: + elif self.pagename == 'file_kill': self._kill_file() - elif self.pagename == PAGE_NAMES['player']: + elif self.pagename == 'player': self._receive_player_command() - elif self.pagename == PAGE_NAMES['purge']: + elif self.pagename == 'purge': self._purge_deleted_files() - elif self.pagename == PAGE_NAMES['yt_queries']: + elif self.pagename == 'yt_queries': self._receive_yt_query() def _receive_downloads(self) -> None: @@ -168,11 +137,11 @@ class _TaskHandler(PlomHttpHandler): self.server.player.load_files_and_mpv(empty=True) elif command.startswith('jump_'): self.server.player.jump_to(int(command.split('_')[1])) - elif command.startswith('up_'): + elif command.startswith(CMD_UP + '_'): self.server.player.move_entry(int(command.split('_')[1])) - elif command.startswith('down_'): + elif command.startswith(CMD_DOWN + '_'): self.server.player.move_entry(int(command.split('_')[1]), False) - elif command.startswith('rm_'): + elif command.startswith(CMD_RM + '_'): self.server.player.remove_by_idx(int(command.split('_')[1])) elif (command.startswith('inject_') or command.startswith('injectplay_')): @@ -197,44 +166,52 @@ class _TaskHandler(PlomHttpHandler): query_data = YoutubeQuery.new_by_request_saved( conn, self.server.config, query_txt) conn.commit() - self._redirect(Path('/') - .joinpath(PAGE_NAMES['yt_query']) - .joinpath(query_data.id_)) + self._redirect(Path('/', 'yt_query', query_data.id_)) def do_GET(self) -> None: # pylint:disable=invalid-name """Map GET requests to handlers for various paths.""" try: - if self.pagename == PAGE_NAMES['download']: + if self.pagename == 'download': self._send_or_download_video() - elif self.pagename == PAGE_NAMES['downloads']: - self._send_downloads() - elif self.pagename == PAGE_NAMES['events']: + elif self.pagename == 'events': self._send_events() - elif self.pagename == PAGE_NAMES['file']: - self._send_file_data() - elif self.pagename == PAGE_NAMES['file_json']: + elif self.pagename == 'file.json': self._send_file_json() - elif self.pagename == PAGE_NAMES['files']: - self._send_files_index() - elif self.pagename == PAGE_NAMES['files_json']: + elif self.pagename == 'files.json': self._send_files_json() - elif self.pagename == PAGE_NAMES['missing']: + elif self.pagename == 'missing': self._send_missing_json() - elif self.pagename == PAGE_NAMES['tags']: - self._send_tags_index() - elif self.pagename == PAGE_NAMES['thumbnails']: + elif self.pagename == 'thumbnails': self._send_thumbnail() - elif self.pagename == PAGE_NAMES['yt_result']: - self._send_yt_result() - elif self.pagename == PAGE_NAMES['yt_queries']: - self._send_yt_queries_index_and_search() - elif self.pagename == PAGE_NAMES['yt_query']: - self._send_yt_query_page() - else: # e.g. for / - self._send_playlist() + elif self.pagename.endswith('.js'): + self._send_js() + else: + self._send_html() except NotFoundException as e: self.send_http(bytes(str(e), encoding='utf8'), code=404) + def _build_ctx(self, target_name: str) -> dict[str, Any]: + handler_name = '_send_' + target_name.replace('.', '_') + return (getattr(self, handler_name)() if hasattr(self, handler_name) + else {}) + + def _send_html(self) -> None: + tmpl_name = f'{self.pagename}.html' + tmpl_path = _PATH_TEMPLATES.joinpath(tmpl_name) + if not tmpl_path.exists(): + self.pagename = 'playlist' + tmpl_name = f'{self.pagename}.html' + ctx = {'pagename': self.pagename, + 'background_color': self.server.config.background_color} + self.send_rendered(Path(tmpl_name), ctx | self._build_ctx(tmpl_name)) + + def _send_js(self) -> None: + ctx = self._build_ctx(self.pagename) + self.send_http( + bytes(self.server.jinja.get_template(self.pagename).render(**ctx), + encoding='utf8'), + [(_HEADER_CONTENT_TYPE, 'text/javascript')]) + def _send_json(self, body: dict | list) -> None: self.send_http(bytes(json_dumps(body), encoding='utf8'), headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)]) @@ -245,7 +222,6 @@ class _TaskHandler(PlomHttpHandler): ) -> None: tmpl_ctx['selected'] = tmpl_ctx.get('selected', '') tmpl_ctx['background_color'] = self.server.config.background_color - tmpl_ctx['page_names'] = PAGE_NAMES self.send_rendered(tmpl_name, tmpl_ctx) def _send_or_download_video(self) -> None: @@ -255,17 +231,10 @@ class _TaskHandler(PlomHttpHandler): file_data = VideoFile.get_by_yt_id(conn, video_id) if not file_data.present: raise NotFoundException - self._redirect(Path('/') - .joinpath(PAGE_NAMES['file']) - .joinpath(file_data.digest.b64)) + self._redirect(Path('/', 'file', file_data.digest.b64)) except NotFoundException: self.server.downloads.q.put(f'queue_{video_id}') - self._redirect(Path('/') - .joinpath(PAGE_NAMES['yt_result']) - .joinpath(video_id)) - - def _send_downloads(self) -> None: - self._send_rendered_template(_NAME_TEMPLATE_DOWNLOADS, {}) + self._redirect(Path('/', 'yt_result', video_id)) def _send_events(self) -> None: self.send_http(headers=[(_HEADER_CONTENT_TYPE, 'text/event-stream'), @@ -333,35 +302,37 @@ class _TaskHandler(PlomHttpHandler): last_updates['download'] = update['time'] payload['download'] = {k: update[k] for k in update if k != 'time'} - if 'downloads' in subscriptions: - if last_updates['downloads'] < self.server.downloads.timestamp: - last_updates['downloads'] = self.server.downloads.timestamp - with DbConn() as conn: - payload['downloads']\ - = self.server.downloads.overview(conn) + if 'downloads' in subscriptions\ + and (last_updates['downloads'] + < self.server.downloads.timestamp): + last_updates['downloads'] = self.server.downloads.timestamp + with DbConn() as conn: + payload['downloads']\ + = self.server.downloads.overview(conn) if not payload: sleep(_EVENTS_UPDATE_INTERVAL_S) - def _send_file_data(self) -> None: + def _send_file_html(self) -> dict[str, Any]: with DbConn() as 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}) + return { + 'allow_edit': self.server.config.allow_file_edit, + 'file': file} 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}) + self._send_json(file.as_dict | {'unused_tags': unused_tags, + 'yt_id': file.yt_id}) - def _send_files_index(self) -> None: + def _send_files_js(self) -> dict[str, Any]: with DbConn() as conn: - showable_tags = sorted(list(VideoFile.all_tags_showable(conn))) - self._send_rendered_template(_NAME_TEMPLATE_FILES, { - 'allow_show_absent': self.server.config.allow_file_edit, - 'showable_tags': showable_tags, - 'needed_tags': self.params.all_for('needed_tag')}) + showable_tags = VideoFile.all_tags_showable(conn) + return {'showable_tags': sorted(list(showable_tags))} + + def _send_files_html(self) -> dict[str, Any]: + return {'allow_show_absent': self.server.config.allow_file_edit} def _send_files_json(self) -> None: with DbConn() as conn: @@ -380,13 +351,11 @@ class _TaskHandler(PlomHttpHandler): self._send_json([f.digest.b64 for f in VideoFile.get_all(conn) if not f.present]) - def _send_tags_index(self) -> None: + def _send_tags_js(self) -> dict[str, Any]: with DbConn() as conn: - tags_to_numbers = [ - {'name': k, 'number': v} for k, v - in VideoFile.showable_tags_to_numbers(conn).items()] - self._send_rendered_template(_NAME_TEMPLATE_TAGS, - {'tags': tags_to_numbers}) + return { + 'tags': [{'name': k, 'number': v} for k, v + in VideoFile.showable_tags_to_numbers(conn).items()]} def _send_thumbnail(self) -> None: filename = Path(self.path_toks[2]) @@ -404,7 +373,7 @@ class _TaskHandler(PlomHttpHandler): with path_thumbnail.open('rb') as f: self.send_http(f.read(), [(_HEADER_CONTENT_TYPE, 'image/jpg')]) - def _send_yt_result(self) -> None: + def _send_yt_result_html(self) -> dict[str, Any]: video_id = YoutubeId(self.path_toks[2]) with DbConn() as conn: linked_queries = [ @@ -414,37 +383,29 @@ class _TaskHandler(PlomHttpHandler): video_data = YoutubeVideo.get_one(conn, video_id) except NotFoundException: video_data = YoutubeVideo(video_id) - self._send_rendered_template( - _NAME_TEMPLATE_YT_RESULT, - {'video_data': video_data, - 'queries': linked_queries, - 'youtube_prefix': ( - YOUTUBE_URL_PREFIX if self.server.config.link_originals - else '')}) - - def _send_yt_queries_index_and_search(self) -> None: + return { + 'video_data': video_data, + 'queries': linked_queries, + 'youtube_prefix': ( + YOUTUBE_URL_PREFIX if self.server.config.link_originals + else '')} + + def _send_yt_queries_html(self) -> dict[str, Any]: with DbConn() as conn: quota_count = QuotaLog.current(conn) queries_data = [ q for q in YoutubeQuery.get_all(conn) if q.retrieved_at > self.server.config.queries_cutoff] queries_data.sort(key=lambda q: q.retrieved_at, reverse=True) - self._send_rendered_template(_NAME_TEMPLATE_YT_QUERIES, - {'queries': queries_data, - 'quota_count': quota_count, - 'selected': 'yt_queries'}) + return {'queries': queries_data, + 'quota_count': quota_count, + 'selected': 'yt_queries'} - def _send_yt_query_page(self) -> None: + def _send_yt_query_html(self) -> dict[str, Any]: query_id = QueryId(self.path_toks[2]) with DbConn() as conn: - query = YoutubeQuery.get_one(conn, str(query_id)) - results = YoutubeVideo.get_all_for_query(conn, query_id) - self._send_rendered_template(_NAME_TEMPLATE_YT_RESULTS, - {'query': query.text, 'videos': results}) - - def _send_playlist(self) -> None: - self._send_rendered_template(_NAME_TEMPLATE_PLAYLIST, - {'selected': 'playlist'}) + return {'query': YoutubeQuery.get_one(conn, str(query_id)).text, + 'videos': YoutubeVideo.get_all_for_query(conn, query_id)} def serve(): diff --git a/src/ytplom/misc.py b/src/ytplom/misc.py index 33a4a9b..8e35920 100644 --- a/src/ytplom/misc.py +++ b/src/ytplom/misc.py @@ -85,6 +85,10 @@ TOK_LOADED = 'downloaded_bytes' MILLION = 1000 * 1000 MEGA = 1024 * 1024 +CMD_UP = 'up' +CMD_DOWN = 'down' +CMD_RM = 'rm' + def ensure_expected_dirs(expected_dirs: list[Path]) -> None: """Ensure existance of expected_dirs _as_ directories.""" @@ -953,7 +957,7 @@ class DownloadsManager: for attr_name in ('_downloaded', '_inherited'): coll = getattr(self, attr_name) if yt_id in coll: - # NB: for some strange remove .remove on target + # NB: for some strange .remove on target # collection will not satisfy assert below … setattr(self, attr_name, [id_ for id_ in coll if id_ != yt_id]) assert yt_id not in self._downloaded + self._inherited @@ -1054,18 +1058,18 @@ class DownloadsManager: if command == 'download_next': Thread(target=self._download_next, daemon=False).start() continue - if command == 'abort': + if command == CMD_RM: yt_id = self._abort_downloading() else: command, arg = command.split('_', maxsplit=1) if command == 'savefile': yt_id = self._savefile(arg) - elif command in {'unqueue', 'up', 'down'}: + elif command in {CMD_RM, CMD_UP, CMD_DOWN}: idx = int(arg) - if command == 'unqueue': + if command == CMD_RM: yt_id = self._unqueue_download(idx) else: - self._move_in_queue(idx, upwards=command == 'up') + self._move_in_queue(idx, upwards=command == CMD_UP) continue else: yt_id = arg diff --git a/src/ytplom/sync.py b/src/ytplom/sync.py index d7bc0c5..252c7a1 100644 --- a/src/ytplom/sync.py +++ b/src/ytplom/sync.py @@ -11,7 +11,6 @@ from scp import SCPClient # type: ignore from ytplom.db import DbConn, DbFile, Hash, PATH_DB from ytplom.misc import (PATH_TEMP, Config, FileDeletionRequest, FlagName, QuotaLog, VideoFile, YoutubeQuery, YoutubeVideo) -from ytplom.http import PAGE_NAMES _PATH_DB_REMOTE = PATH_TEMP.joinpath('remote_db.sql') @@ -89,7 +88,7 @@ def _sync_dbs(scp: SCPClient) -> None: def _urls_here_and_there(config: Config, page_name: str) -> tuple[str, ...]: - return tuple(f'http://{host}:{port}/{PAGE_NAMES[page_name]}' + return tuple(f'http://{host}:{port}/{page_name}' for host, port in ((config.remote, config.port_remote), (config.host, config.port)))