diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2a3e3f --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 xenofem + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/dlibrary/dlibrary.py b/dlibrary/dlibrary.py index a24638d..986a824 100755 --- a/dlibrary/dlibrary.py +++ b/dlibrary/dlibrary.py @@ -4,10 +4,12 @@ import argparse import asyncio import importlib_resources as resources from pathlib import Path +from os import getenv from os.path import relpath, splitext import re import shutil import sqlite3 +import textwrap from urllib.parse import urlparse import zipfile @@ -296,12 +298,15 @@ def metadata(args): con.close() -def copy_contents(src, dest): +def copy_recursive(src, dest): dest.mkdir(parents=True, exist_ok=True) for item in src.iterdir(): - shutil.copyfile(item, dest / item.name) + if item.is_dir() and not item.is_symlink(): + copy_recursive(item, dest / item.name) + else: + shutil.copyfile(item, dest / item.name) -def publish(args): +def generate(args): jenv = Environment( loader=PackageLoader("dlibrary"), autoescape=select_autoescape() @@ -347,7 +352,7 @@ def publish(args): with open(viewer_dir / 'index.html', 'w') as f: f.write(viewer_template.render(depth=3, work=work, title=title, images=images)) - def make_categorization(categorization, query, work_filter): + def make_categorization(categorization, query, work_filter, work_style_cards=False): categorization_dir = args.destdir / 'site' / categorization cats = [cat for (cat,) in cur.execute(query)] @@ -374,6 +379,7 @@ def publish(args): categorization=categorization, categories=cats, samples=cat_samples, + work_style_cards=work_style_cards, )) make_categorization( @@ -395,10 +401,11 @@ def publish(args): 'series', 'SELECT DISTINCT series FROM works WHERE series NOT NULL ORDER BY series', lambda series: lambda work: work['series'] == series, + work_style_cards=True, ) with resources.as_file(resources.files("dlibrary")) as r: - copy_contents(r / 'static', args.destdir / 'site' / 'static') + copy_recursive(r / 'static', args.destdir / 'site' / 'static') with open(args.destdir / 'site' / 'index.html', 'w') as f: f.write(list_template.render(depth=0, works=works)) @@ -406,12 +413,33 @@ def publish(args): con.close() -argparser = argparse.ArgumentParser(prog='dlibrary') +argparser = argparse.ArgumentParser( + prog='dlibrary', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + Organize DRM-free works purchased from DLSite into a library + that can be viewed in a web browser. + + Intended workflow: + - `extract` a collection of zipfiles downloaded from DLSite + into DLibrary's data directory, giving each work its own + subfolder. + - `fetch` metadata and thumbnail images for extracted works + from DLSite. + - `collate` and/or `manual-collate` extracted works, + producing a single sequence of image files (or symlinks + into the extracted data, when possible) for each work. + - Manually adjust works' `metadata` when necessary. + - `generate` a static website providing a catalog and viewer + for all collated works. + """), +) + argparser.add_argument( '-d', '--destdir', type=Path, - default=Path('./dlibrary'), - help='directory to store dlibrary content and metadata to (default: ./dlibrary)', + default=Path(getenv('DLIBRARY_DIR', './dlibrary')), + help='directory to store dlibrary content and metadata to (default: $DLIBRARY_DIR or ./dlibrary)', ) subparsers = argparser.add_subparsers(title="subcommands", required=True) @@ -433,23 +461,79 @@ parser_extract.set_defaults(func=extract) parser_fetch = subparsers.add_parser('fetch', help='fetch metadata and thumbnails') parser_fetch.set_defaults(func=fetch) -parser_collate = subparsers.add_parser('collate', help='collate a single sequence of image files for each work') +parser_collate = subparsers.add_parser( + 'collate', + help='collate each work into a sequence of image files', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + For each extracted work that has not already been collated, + DLibrary will attempt to intuit its structure as follows: + + - Enter the work's directory. If the directory contains + nothing except a single subdirectory (ignoring a few types + of files that are definitely not relevant), traverse + downwards repeatedly. + - If the current directory contains nothing except a single + PDF (again, ignoring irrelevant files), attempt to extract + a series of images from the PDF. This process expects that + each page of the PDF consists of a single embedded image, + which will be extracted at full resolution. Support for + more complex PDFs is not yet implemented. + - If the current directory contains nothing except image + files, and the image files are named in a way that clearly + indicates a complete numerical order (each filename + consists of a shared prefix followed by a distinct + number), symlink files in the inferred order. + - Otherwise, skip processing this work for now. + + DLibrary can be given "collation hints" which provide + alternative starting points for this search process. A hint + is a path under $DLIBRARY_DIR/extract/[work id]/ + indicating a different directory or PDF file to begin the + search process for that work, rather than starting at the + top level of the extracted data. There can be at most one + hint per work; for more complicated scenarios where a work + includes multiple folders that need to be collated together, + or where filenames do not clearly indicate an ordering, use + `manual-collate` instead. + """), +) parser_collate.add_argument( 'hints', metavar='PATH', type=Path, nargs='*', - help='manually-specified paths of subdirectories or PDFs within extraction folders, at most one per work', + help='paths within extraction folders as collation hints' ) parser_collate.set_defaults(func=collate) -parser_manual_collate = subparsers.add_parser('manual-collate', help='collate a specific work manually, specifying all paths to include') +parser_manual_collate = subparsers.add_parser( + 'manual-collate', + help='collate a single work manually', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + All provided paths must be under $DLIBRARY_DIR/extract/[work id]/ + for the work being manually collated. `manual-collate` can + only handle one work at a time. Paths are used as follows: + + - If a path is a directory, all *image files* immediately + inside that directory will be appended to the sequence. If + files are named in a way which indicates a clear ordering, + that ordering will be used. Otherwise, filenames will be + sorted lexicographically. Non-image files and + subdirectories will be ignored. + - If a path is an image file, that image file will be + appended to the sequence. + - If a path is a PDF file, page images will be extracted + from that PDF and appended to the sequence. +"""), +) parser_manual_collate.add_argument( 'paths', metavar='PATH', type=Path, nargs='+', - help='paths of files (images to symlink, pdfs to extract) or directories (symlink all images in the directory, no recursion, best-effort sorting)' + help='paths within a single work to be collated in sequence', ) parser_manual_collate.set_defaults(func=manual_collate) @@ -462,8 +546,20 @@ parser_metadata.add_argument( ) parser_metadata.set_defaults(func=metadata) -parser_publish = subparsers.add_parser('publish', help='generate HTML/CSS/JS for library site') -parser_publish.set_defaults(func=publish) +parser_generate = subparsers.add_parser( + 'generate', + help='generate HTML/CSS/JS for library site', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + The static site will be generated under $DLIBRARY_DIR/site/ + and can be served by pointing an HTTP server at that + directory. Note that some files inside the static site + hierarchy will be symlinks into $DLIBRARY_DIR/extract/ + outside the site hierarchy, so make sure your HTTP server + will allow those symlinks to be read. + """), +) +parser_generate.set_defaults(func=generate) def main(): args = argparser.parse_args() diff --git a/dlibrary/static/dlibrary.css b/dlibrary/static/dlibrary.css index d02110d..03c78d4 100644 --- a/dlibrary/static/dlibrary.css +++ b/dlibrary/static/dlibrary.css @@ -51,12 +51,19 @@ body { .work-container { display: flex; + flex-wrap: wrap; justify-content: center; gap: 30px; } +.work-preview img { + max-width: 100%; +} + .work-info { - max-width: min(50%, 500px); + flex-basis: 40%; + flex-grow: 1; + max-width: 500px; } .work-info td, .work-info th { diff --git a/dlibrary/static/viewer.css b/dlibrary/static/viewer.css index 9045ee9..f521e83 100644 --- a/dlibrary/static/viewer.css +++ b/dlibrary/static/viewer.css @@ -4,3 +4,39 @@ html, body { padding: 0; margin: 0; } + +.tap { + background: #000000; + opacity: 0.8; + position: fixed; + animation: 2s linear forwards fade; + display: flex; + justify-content: center; + align-items: center; +} + +@keyframes fade { + to { opacity: 0; } +} + +#tap-left, #tap-right { + position: fixed; + bottom: 0px; + width: 30vw; + height: 100vh; +} + +#tap-left { + left: 0px; +} + +#tap-right { + right: 0px; +} + +#tap-back { + top: 0px; + left: 30vw; + width: 40vw; + height: 20vh; +} diff --git a/dlibrary/static/viewer.js b/dlibrary/static/viewer.js index f2dd772..ebf9c62 100644 --- a/dlibrary/static/viewer.js +++ b/dlibrary/static/viewer.js @@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', () => { let paused = true; let interval; let elapsed = 0; + let rtl = (localStorage.getItem(`${WORK_ID}-rtl`) !== "false"); function startTimer() { if (interval) { @@ -78,16 +79,46 @@ document.addEventListener('DOMContentLoaded', () => { } } + function left() { + if (currentPage === 0) { + rtl = true; + localStorage.setItem(`${WORK_ID}-rtl`, rtl); + } + changePage(currentPage + (rtl ? 1 : -1)); + } + + function right() { + if (currentPage === 0) { + rtl = false; + localStorage.setItem(`${WORK_ID}-rtl`, rtl); + } + changePage(currentPage + (rtl ? -1 : 1)); + } + + function exitToWork() { + changeDuration(duration, true); + localStorage.setItem(`${WORK_ID}-currentPage`, 0); + window.location.href = `../${INDEX}`; + } + + function hideTapZones() { + for (const el of document.getElementsByClassName('tap')) { + el.style.opacity = 0; + } + } + changePage(currentPage); changeDuration(duration, paused); - document.onkeydown = event =>{ + document.onkeydown = event => { + hideTapZones(); + switch (event.keyCode) { case 32: //space changeDuration(duration, !paused); break; case 37: //left - changePage(currentPage - 1); + left(); break; case 38: //up if (2 <= duration && duration <= 10) { @@ -99,7 +130,7 @@ document.addEventListener('DOMContentLoaded', () => { } break; case 39: //right - changePage(currentPage + 1); + right(); break; case 40: //down if (duration < 10) { @@ -111,10 +142,13 @@ document.addEventListener('DOMContentLoaded', () => { } break; case 13: //enter - changeDuration(duration, true); - localStorage.setItem(`${WORK_ID}-currentPage`, 0); - window.location.href = "../"; + exitToWork(); break; } }; + + document.onclick = hideTapZones; + document.getElementById("tap-left").onclick = left; + document.getElementById("tap-right").onclick = right; + document.getElementById("tap-back").onclick = exitToWork; }); diff --git a/dlibrary/templates/base.html b/dlibrary/templates/base.html index 40cb671..1fb0b0a 100644 --- a/dlibrary/templates/base.html +++ b/dlibrary/templates/base.html @@ -6,7 +6,7 @@