funoteka 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# /etc/funoteka/env — install as 0600, root-owned.
|
|
2
|
+
#
|
|
3
|
+
# This is where a native Linux deployment keeps its settings, and it is the same
|
|
4
|
+
# vocabulary as `.env.example` for Docker and the Windows service wrapper behind
|
|
5
|
+
# it. The environment wins over a config file, so anything written here is the
|
|
6
|
+
# last word.
|
|
7
|
+
|
|
8
|
+
# ---- the meta layer ----------------------------------------------------------
|
|
9
|
+
# `StateDirectory=funoteka` in the unit makes /var/lib/funoteka for you, and the
|
|
10
|
+
# unit lets the service write there and nowhere else under a read-only /.
|
|
11
|
+
FUNOTEKA_DB=/var/lib/funoteka/funoteka.db
|
|
12
|
+
|
|
13
|
+
# ---- who may listen ----------------------------------------------------------
|
|
14
|
+
FUNOTEKA_HOST=0.0.0.0
|
|
15
|
+
FUNOTEKA_PORT=4533
|
|
16
|
+
FUNOTEKA_USER=demo
|
|
17
|
+
FUNOTEKA_PASSWORD=change-me
|
|
18
|
+
# Or a key instead of a password (OpenSubsonic `apiKeyAuthentication`).
|
|
19
|
+
# FUNOTEKA_APIKEY=
|
|
20
|
+
|
|
21
|
+
# ---- who may operate ---------------------------------------------------------
|
|
22
|
+
# The admin surface does not exist without a token. Generate one:
|
|
23
|
+
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
24
|
+
FUNOTEKA_ADMIN_TOKEN=
|
|
25
|
+
FUNOTEKA_ADMIN_PORT=4534
|
|
26
|
+
|
|
27
|
+
# ---- the collection ----------------------------------------------------------
|
|
28
|
+
# The roots the scanner reads live in the meta layer, not here: `POST /roots`
|
|
29
|
+
# adds one on the admin port and `POST /scan` reads it (DEPLOY.md §4). By hand,
|
|
30
|
+
# once, it is the CLI — which records the root as it scans:
|
|
31
|
+
# sudo -u funoteka /usr/bin/node /opt/funoteka/src/cli.ts scan /srv/music
|
|
32
|
+
|
|
33
|
+
# ---- the rest, when the defaults do not fit ----------------------------------
|
|
34
|
+
# ffmpeg is only needed to cut cue tracks out of an m4a/MP4 image. Without it
|
|
35
|
+
# those tracks are refused with a reason and everything else works.
|
|
36
|
+
# FUNOTEKA_FFMPEG=/usr/bin/ffmpeg
|
|
37
|
+
# The journal already collects this server's output; a file of its own is for
|
|
38
|
+
# the machines where nobody reads the journal.
|
|
39
|
+
# FUNOTEKA_LOG_FILE=/var/lib/funoteka/funoteka.log
|
|
40
|
+
# One line per request — how "why will this phone not sync" gets answered.
|
|
41
|
+
# FUNOTEKA_LOG_REQUESTS=1
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# funoteka as a Linux service.
|
|
2
|
+
#
|
|
3
|
+
# sudo install -Dm644 deploy/systemd/funoteka.service /etc/systemd/system/funoteka.service
|
|
4
|
+
# sudo install -Dm600 deploy/systemd/funoteka.env.example /etc/funoteka/env
|
|
5
|
+
# sudo editor /etc/funoteka/env # credentials, admin token, MUSIC path
|
|
6
|
+
# sudo useradd --system --home /var/lib/funoteka funoteka
|
|
7
|
+
# sudo systemctl daemon-reload && sudo systemctl enable --now funoteka
|
|
8
|
+
#
|
|
9
|
+
# The unit is written for the *native* deployment — Node and ffmpeg installed on
|
|
10
|
+
# the machine. If Docker is available, `docker compose up -d` is the shorter road
|
|
11
|
+
# and this file is not needed.
|
|
12
|
+
#
|
|
13
|
+
# **`Restart=always` is what makes `POST /restart` a restart.** The admin surface
|
|
14
|
+
# answers 200 and exits; systemd is what brings it back. `FUNOTEKA_SUPERVISED=1`
|
|
15
|
+
# below is the server being told so, and without both halves the route refuses
|
|
16
|
+
# with a reason rather than stopping the service.
|
|
17
|
+
|
|
18
|
+
[Unit]
|
|
19
|
+
Description=funoteka — Subsonic-compatible music library
|
|
20
|
+
Documentation=https://github.com/ (see DEPLOY.md)
|
|
21
|
+
After=network-online.target
|
|
22
|
+
Wants=network-online.target
|
|
23
|
+
|
|
24
|
+
[Service]
|
|
25
|
+
Type=simple
|
|
26
|
+
User=funoteka
|
|
27
|
+
Group=funoteka
|
|
28
|
+
WorkingDirectory=/opt/funoteka
|
|
29
|
+
|
|
30
|
+
# Secrets live here and not in this unit: the file is 0600 and root-owned, while
|
|
31
|
+
# a unit file is world-readable and ends up in `systemctl cat` output.
|
|
32
|
+
EnvironmentFile=/etc/funoteka/env
|
|
33
|
+
# What tells the server that something will start it again — see the note above.
|
|
34
|
+
Environment=FUNOTEKA_SUPERVISED=1
|
|
35
|
+
# **Where the config file is, and it has to be somewhere the service may write.**
|
|
36
|
+
# Without this the file resolves against the working directory — `/opt/funoteka`
|
|
37
|
+
# — which `ProtectSystem=strict` makes read-only, so `POST /config` and
|
|
38
|
+
# `POST /user` would fail on a native deployment while working in a container.
|
|
39
|
+
# The state directory below is the one place this service may write.
|
|
40
|
+
Environment=FUNOTEKA_CONFIG=/var/lib/funoteka/funoteka.json
|
|
41
|
+
|
|
42
|
+
# `/usr/bin/node` rather than `node`: a service does not read a login shell's
|
|
43
|
+
# PATH, and a unit that silently fails to find its interpreter is a service that
|
|
44
|
+
# is "not running" with nothing in the journal but an exit code.
|
|
45
|
+
ExecStart=/usr/bin/node /opt/funoteka/src/cli.ts serve
|
|
46
|
+
|
|
47
|
+
Restart=always
|
|
48
|
+
RestartSec=5s
|
|
49
|
+
|
|
50
|
+
# A stop waits for the process to close its sockets and its meta layer rather
|
|
51
|
+
# than killing it: the server handles SIGTERM, and what it does with it is
|
|
52
|
+
# exactly what a clean shutdown is. The timeout is the backstop for a client
|
|
53
|
+
# holding a stream open.
|
|
54
|
+
KillSignal=SIGTERM
|
|
55
|
+
TimeoutStopSec=20s
|
|
56
|
+
|
|
57
|
+
# The state directory, created and owned by systemd, and the only place under a
|
|
58
|
+
# read-only filesystem this service may write. Point FUNOTEKA_DB here.
|
|
59
|
+
StateDirectory=funoteka
|
|
60
|
+
ReadWritePaths=/var/lib/funoteka
|
|
61
|
+
|
|
62
|
+
# A server that only reads a collection and writes one directory has no use for
|
|
63
|
+
# the privileges of a person. Each of these closes a way out of the service if
|
|
64
|
+
# the process is ever made to do something it was not meant to.
|
|
65
|
+
NoNewPrivileges=true
|
|
66
|
+
ProtectSystem=strict
|
|
67
|
+
# `read-only` and not `yes`: this collection frequently lives under /home, and a
|
|
68
|
+
# unit that could not read it would be a service that started and served nothing.
|
|
69
|
+
ProtectHome=read-only
|
|
70
|
+
PrivateTmp=true
|
|
71
|
+
PrivateDevices=true
|
|
72
|
+
ProtectKernelTunables=true
|
|
73
|
+
ProtectControlGroups=true
|
|
74
|
+
RestrictSUIDSGID=true
|
|
75
|
+
|
|
76
|
+
[Install]
|
|
77
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
funoteka as a Windows service — WinSW's configuration.
|
|
3
|
+
|
|
4
|
+
Do not copy this file to the service by hand: `install-service.ps1` fills in
|
|
5
|
+
the placeholders below and installs it, because the paths it needs are the ones
|
|
6
|
+
only that script knows (where node is, where this checkout is, where the data
|
|
7
|
+
directory is).
|
|
8
|
+
|
|
9
|
+
**Every secret lives in the config file and not here.** A WinSW XML sits beside
|
|
10
|
+
the wrapped executable and is readable by whoever can read that directory,
|
|
11
|
+
while `FUNOTEKA_CONFIG` points at a file whose ACL the install script restricts
|
|
12
|
+
to the service's own account. That is the same split the systemd unit makes
|
|
13
|
+
with its `EnvironmentFile`, and for the same reason.
|
|
14
|
+
|
|
15
|
+
**`<onfailure action="restart"/>` is what makes `POST /restart` a restart.**
|
|
16
|
+
The admin surface answers 200 and exits with 75 (`EX_TEMPFAIL`), which is a
|
|
17
|
+
failure as far as this file is concerned — that is exactly why the code is not
|
|
18
|
+
0, and why the other two deployments need nothing special: Docker and systemd
|
|
19
|
+
restart on a clean exit too.
|
|
20
|
+
-->
|
|
21
|
+
<service>
|
|
22
|
+
<id>funoteka</id>
|
|
23
|
+
<name>funoteka</name>
|
|
24
|
+
<description>Subsonic-compatible music library — smart scanner, Subsonic API, admin surface</description>
|
|
25
|
+
|
|
26
|
+
<executable>{{NODE}}</executable>
|
|
27
|
+
<arguments>"{{REPO}}\src\cli.ts" serve</arguments>
|
|
28
|
+
<workingdirectory>{{DATA}}</workingdirectory>
|
|
29
|
+
|
|
30
|
+
<startmode>Automatic</startmode>
|
|
31
|
+
<!-- Gives the network and any mounted collection time to be there; a service
|
|
32
|
+
that starts before the disk it serves would scan an empty directory. -->
|
|
33
|
+
<delayedAutoStart>true</delayedAutoStart>
|
|
34
|
+
|
|
35
|
+
<!-- The meta layer, the log and the config, all in one directory — and the
|
|
36
|
+
only directory this service needs to write. -->
|
|
37
|
+
<env name="FUNOTEKA_CONFIG" value="{{DATA}}\funoteka.json" />
|
|
38
|
+
<env name="FUNOTEKA_DB" value="{{DATA}}\funoteka.db" />
|
|
39
|
+
<env name="FUNOTEKA_LOG_FILE" value="{{DATA}}\funoteka.log" />
|
|
40
|
+
<!-- What tells the server something will start it again — see the note above. -->
|
|
41
|
+
<env name="FUNOTEKA_SUPERVISED" value="1" />
|
|
42
|
+
|
|
43
|
+
<onfailure action="restart" delay="10 sec" />
|
|
44
|
+
<!-- A service that has restarted cleanly for an hour is no longer failing, so
|
|
45
|
+
one later crash is treated as the first. -->
|
|
46
|
+
<resetfailure>1 hour</resetfailure>
|
|
47
|
+
|
|
48
|
+
<!-- A stop waits for the sockets and the meta layer to be closed: the server
|
|
49
|
+
handles SIGTERM, and Windows delivers it to a process WinSW asks politely.
|
|
50
|
+
The timeout is the backstop for a client holding a stream open. -->
|
|
51
|
+
<stoptimeout>20 sec</stoptimeout>
|
|
52
|
+
<!-- The server spawns ffmpeg for cue segments; that child must not outlive the
|
|
53
|
+
service, and killing the parent first is what leaves it orphaned. -->
|
|
54
|
+
<stopparentprocessfirst>false</stopparentprocessfirst>
|
|
55
|
+
|
|
56
|
+
<logmode>append</logmode>
|
|
57
|
+
<logpath>{{DATA}}\logs</logpath>
|
|
58
|
+
</service>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Install funoteka as a Windows service, or take it away.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Wraps this checkout's `node src/cli.ts serve` in a Windows service using WinSW
|
|
7
|
+
— a small executable that does the one thing Node cannot: talk to the Service
|
|
8
|
+
Control Manager. Node has no way to be a service on its own, and the
|
|
9
|
+
alternatives are all worse for a project with no dependencies: a .NET project
|
|
10
|
+
to build, or a scheduled task that only starts when somebody logs in.
|
|
11
|
+
|
|
12
|
+
Run it from an **elevated** PowerShell. Nothing is downloaded without its hash
|
|
13
|
+
being checked, and nothing is written outside the data directory and the
|
|
14
|
+
service's own folder.
|
|
15
|
+
|
|
16
|
+
**This script has not been run end to end by the person who wrote it.** It
|
|
17
|
+
installs a service, which needs an elevated shell and a machine to keep the
|
|
18
|
+
service on; the Docker path is the one that was verified. Treat the first run
|
|
19
|
+
as the verification, and read what it says.
|
|
20
|
+
|
|
21
|
+
.EXAMPLE
|
|
22
|
+
.\install-service.ps1 -Data 'C:\ProgramData\funoteka'
|
|
23
|
+
|
|
24
|
+
.EXAMPLE
|
|
25
|
+
.\install-service.ps1 -Remove
|
|
26
|
+
#>
|
|
27
|
+
[CmdletBinding()]
|
|
28
|
+
param(
|
|
29
|
+
# Everything this deployment owns: the meta layer, the log, the config and
|
|
30
|
+
# WinSW's own files. Defaults to C:\ProgramData\funoteka, which is where a
|
|
31
|
+
# machine-wide service's state belongs.
|
|
32
|
+
[string] $Data = (Join-Path $env:ProgramData 'funoteka'),
|
|
33
|
+
|
|
34
|
+
# Where WinSW is put. Its own directory because the service's executable is
|
|
35
|
+
# one of its files, and a service looking for its wrapper among the sources
|
|
36
|
+
# is a service that breaks on the next `git pull`.
|
|
37
|
+
[string] $ServiceDir = (Join-Path $env:ProgramData 'funoteka\service'),
|
|
38
|
+
|
|
39
|
+
# The service's display name; also the XML's id.
|
|
40
|
+
[string] $Name = 'funoteka',
|
|
41
|
+
|
|
42
|
+
[switch] $Remove,
|
|
43
|
+
|
|
44
|
+
[switch] $SkipDownload
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
$ErrorActionPreference = 'Stop'
|
|
48
|
+
|
|
49
|
+
# WinSW 2.12.0, pinned — and the hash is of the asset as published on that
|
|
50
|
+
# release, computed after downloading it from GitHub. A version without a hash is
|
|
51
|
+
# a download that can change under the same name; a hash without a version is one
|
|
52
|
+
# nobody can look up.
|
|
53
|
+
$WinSwVersion = '2.12.0'
|
|
54
|
+
$WinSwUrl = "https://github.com/winsw/winsw/releases/download/v$WinSwVersion/WinSW-x64.exe"
|
|
55
|
+
$WinSwSha256 = '05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da'
|
|
56
|
+
|
|
57
|
+
function Assert-Admin {
|
|
58
|
+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
59
|
+
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
|
60
|
+
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
61
|
+
throw 'This needs an elevated PowerShell: installing a service writes to the machine, not to your profile.'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Get-WinSw {
|
|
66
|
+
param([string] $Where)
|
|
67
|
+
|
|
68
|
+
if ($SkipDownload) {
|
|
69
|
+
if (-not (Test-Path $Where)) { throw "-SkipDownload was given but there is no WinSW at $Where" }
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Write-Host "Downloading WinSW $WinSwVersion ..."
|
|
74
|
+
$temp = Join-Path $env:TEMP "winsw-$WinSwVersion.exe"
|
|
75
|
+
Invoke-WebRequest -Uri $WinSwUrl -OutFile $temp -UseBasicParsing
|
|
76
|
+
|
|
77
|
+
$hash = (Get-FileHash -Algorithm SHA256 -Path $temp).Hash.ToLowerInvariant()
|
|
78
|
+
if ($hash -ne $WinSwSha256) {
|
|
79
|
+
Remove-Item -Force $temp
|
|
80
|
+
throw "WinSW $WinSwVersion did not match its published hash.`n expected $WinSwSha256`n got $hash`nNothing was installed."
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
New-Item -ItemType Directory -Force -Path (Split-Path $Where) | Out-Null
|
|
84
|
+
Move-Item -Force $temp $Where
|
|
85
|
+
Write-Host " hash ok: $hash"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------- uninstall --
|
|
89
|
+
if ($Remove) {
|
|
90
|
+
Assert-Admin
|
|
91
|
+
$exe = Join-Path $ServiceDir "$Name.exe"
|
|
92
|
+
if (Test-Path $exe) {
|
|
93
|
+
Write-Host "Stopping and uninstalling $Name ..."
|
|
94
|
+
# WinSW uninstalls itself; a service file removed by hand leaves the SCM
|
|
95
|
+
# holding an entry that points at nothing.
|
|
96
|
+
& $exe stop 2>$null
|
|
97
|
+
& $exe uninstall 2>$null
|
|
98
|
+
} else {
|
|
99
|
+
Write-Host "No service wrapper at $exe — nothing to uninstall."
|
|
100
|
+
}
|
|
101
|
+
Write-Host "The data in $Data was left alone. Remove it by hand if you mean to."
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ install --
|
|
106
|
+
Assert-Admin
|
|
107
|
+
|
|
108
|
+
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
|
109
|
+
$node = (Get-Command node -ErrorAction Stop).Source
|
|
110
|
+
|
|
111
|
+
Write-Host "funoteka service"
|
|
112
|
+
Write-Host " repo $repo"
|
|
113
|
+
Write-Host " node $node"
|
|
114
|
+
Write-Host " data $Data"
|
|
115
|
+
|
|
116
|
+
New-Item -ItemType Directory -Force -Path $Data | Out-Null
|
|
117
|
+
New-Item -ItemType Directory -Force -Path $ServiceDir | Out-Null
|
|
118
|
+
|
|
119
|
+
# The config file, from the example, only if there is not one already: this
|
|
120
|
+
# script installs a service, and overwriting a deployment's credentials would be
|
|
121
|
+
# it deciding something that is not its business.
|
|
122
|
+
$config = Join-Path $Data 'funoteka.json'
|
|
123
|
+
if (-not (Test-Path $config)) {
|
|
124
|
+
Copy-Item (Join-Path $repo 'funoteka.json.example') $config
|
|
125
|
+
Write-Host ""
|
|
126
|
+
Write-Host " Wrote $config from the example."
|
|
127
|
+
Write-Host " **Edit it before starting the service**: it needs FUNOTEKA_USER/PASSWORD and an admin token."
|
|
128
|
+
Write-Host ""
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Get-WinSw -Where (Join-Path $ServiceDir "$Name.exe")
|
|
132
|
+
|
|
133
|
+
$template = Get-Content -Raw (Join-Path $PSScriptRoot 'funoteka-service.xml')
|
|
134
|
+
$xml = $template.
|
|
135
|
+
Replace('{{NODE}}', $node).
|
|
136
|
+
Replace('{{REPO}}', $repo).
|
|
137
|
+
Replace('{{DATA}}', $Data)
|
|
138
|
+
$xmlPath = Join-Path $ServiceDir "$Name.xml"
|
|
139
|
+
Set-Content -Path $xmlPath -Value $xml -Encoding utf8
|
|
140
|
+
Write-Host " wrote $xmlPath"
|
|
141
|
+
|
|
142
|
+
$exe = Join-Path $ServiceDir "$Name.exe"
|
|
143
|
+
& $exe install
|
|
144
|
+
if ($LASTEXITCODE -ne 0) { throw "WinSW could not install the service (exit $LASTEXITCODE)" }
|
|
145
|
+
|
|
146
|
+
# The config carries the credentials, so it is readable by the account the
|
|
147
|
+
# service runs as and by administrators — and by nobody else. This is the one
|
|
148
|
+
# thing on Windows that stands in for the systemd unit's 0600 EnvironmentFile.
|
|
149
|
+
icacls $config /inheritance:r /grant:r 'SYSTEM:(R)' /grant:r 'Administrators:(R)' | Out-Null
|
|
150
|
+
|
|
151
|
+
Write-Host ""
|
|
152
|
+
Write-Host "Installed. Then:"
|
|
153
|
+
Write-Host " Start-Service $Name"
|
|
154
|
+
Write-Host " Get-Service $Name"
|
|
155
|
+
Write-Host " Invoke-RestMethod http://127.0.0.1:4533/health"
|
|
156
|
+
Write-Host ""
|
|
157
|
+
Write-Host "The first run has nothing to serve — scan the collection once:"
|
|
158
|
+
Write-Host " & '$node' '$repo\src\cli.ts' scan 'D:\Music'"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
const SUBJECTS = {
|
|
3
|
+
track: { table: 'track_annotation', column: 'track_id' },
|
|
4
|
+
album: { table: 'album_annotation', column: 'album_id' },
|
|
5
|
+
artist: { table: 'artist_annotation', column: 'artist_id' },
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The mark on one subject, or nothing when there is none.
|
|
9
|
+
*
|
|
10
|
+
* Starring twice keeps the first date: a client that re-sends what it already
|
|
11
|
+
* said has not changed when it said it, and a timestamp that moved on every
|
|
12
|
+
* sync would be a `starred` a client keeps re-reading.
|
|
13
|
+
*/
|
|
14
|
+
export function star(db, kind, id) {
|
|
15
|
+
const { table, column } = SUBJECTS[kind];
|
|
16
|
+
db.prepare(`INSERT INTO ${table} (${column}, starred_at, rating) VALUES (?, ?, NULL)
|
|
17
|
+
ON CONFLICT (${column}) DO UPDATE SET starred_at = COALESCE(${table}.starred_at, excluded.starred_at)`).run(id, new Date().toISOString());
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Take the star off, and leave the rating alone.
|
|
21
|
+
*
|
|
22
|
+
* The two are independent in the protocol — a client may rate without starring
|
|
23
|
+
* and unstar without unrating — so this clears one field and then drops the row
|
|
24
|
+
* only if that left it saying nothing.
|
|
25
|
+
*/
|
|
26
|
+
export function unstar(db, kind, id) {
|
|
27
|
+
const { table, column } = SUBJECTS[kind];
|
|
28
|
+
withTransaction(db, () => {
|
|
29
|
+
db.prepare(`UPDATE ${table} SET starred_at = NULL WHERE ${column} = ?`).run(id);
|
|
30
|
+
db.prepare(`DELETE FROM ${table} WHERE ${column} = ? AND rating IS NULL`).run(id);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Set the rating — zero means "no rating", which is the protocol's own spelling.
|
|
35
|
+
*
|
|
36
|
+
* Refused outside 1..5 by the caller (`api/annotation.ts`), not clamped here: a
|
|
37
|
+
* rating of 7 is a client with a bug, and quietly storing 5 would tell it the
|
|
38
|
+
* number it sent was accepted.
|
|
39
|
+
*/
|
|
40
|
+
export function setRating(db, kind, id, rating) {
|
|
41
|
+
const { table, column } = SUBJECTS[kind];
|
|
42
|
+
withTransaction(db, () => {
|
|
43
|
+
if (rating <= 0) {
|
|
44
|
+
db.prepare(`UPDATE ${table} SET rating = NULL WHERE ${column} = ?`).run(id);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
db.prepare(`INSERT INTO ${table} (${column}, starred_at, rating) VALUES (?, NULL, ?)
|
|
48
|
+
ON CONFLICT (${column}) DO UPDATE SET rating = excluded.rating`).run(id, rating);
|
|
49
|
+
}
|
|
50
|
+
db.prepare(`DELETE FROM ${table} WHERE ${column} = ? AND rating IS NULL AND starred_at IS NULL`).run(id);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Everything starred of one kind, most recently starred first.
|
|
55
|
+
*
|
|
56
|
+
* The order is the protocol's suggestion and the only one that means anything
|
|
57
|
+
* here: a starred list is a list of what somebody liked lately, and the
|
|
58
|
+
* timestamps are the only thing the table says about when.
|
|
59
|
+
*/
|
|
60
|
+
export function starredIds(db, kind) {
|
|
61
|
+
const { table, column } = SUBJECTS[kind];
|
|
62
|
+
return db
|
|
63
|
+
.prepare(`SELECT ${column} AS id FROM ${table}
|
|
64
|
+
WHERE starred_at IS NOT NULL
|
|
65
|
+
ORDER BY starred_at DESC, ${column}`)
|
|
66
|
+
.all().map((row) => row.id);
|
|
67
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Every statement a rescan cannot rebuild, as one document. */
|
|
2
|
+
export function exportState(db, schema, now = new Date()) {
|
|
3
|
+
return {
|
|
4
|
+
funoteka: 'export',
|
|
5
|
+
version: 1,
|
|
6
|
+
at: now.toISOString(),
|
|
7
|
+
schema,
|
|
8
|
+
naming: 'a track is (root path, file path within it, segment start in ms — null for a whole file)',
|
|
9
|
+
notIncluded: {
|
|
10
|
+
scan: 'albums, tracks and everything derived from the files: a rescan rebuilds them and the files are on disk',
|
|
11
|
+
secrets: 'the password and the bootstrap api key — they are in the config file, which is one small file to copy',
|
|
12
|
+
history: 'play counts and the play queue: activity rather than decisions, and it grows without bound',
|
|
13
|
+
},
|
|
14
|
+
sensitive: 'the api keys below are credentials: this document is worth as much as they are',
|
|
15
|
+
playlists: db
|
|
16
|
+
.prepare('SELECT id, name, comment, public, created_at, changed_at FROM playlist ORDER BY id')
|
|
17
|
+
.all()
|
|
18
|
+
.map((row) => {
|
|
19
|
+
const one = row;
|
|
20
|
+
return {
|
|
21
|
+
name: one.name,
|
|
22
|
+
comment: one.comment,
|
|
23
|
+
public: one.public === 1,
|
|
24
|
+
createdAt: one.created_at,
|
|
25
|
+
changedAt: one.changed_at,
|
|
26
|
+
entries: db
|
|
27
|
+
.prepare(`SELECT r.path AS root, f.rel_path AS file, t.segment_start_ms AS at
|
|
28
|
+
FROM playlist_track pt JOIN track t ON t.id = pt.track_id
|
|
29
|
+
JOIN file f ON f.id = t.file_id JOIN root r ON r.id = f.root_id
|
|
30
|
+
WHERE pt.playlist_id = ? ORDER BY pt.position`)
|
|
31
|
+
.all(one.id),
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
starred: {
|
|
35
|
+
tracks: db
|
|
36
|
+
.prepare(`SELECT r.path AS root, f.rel_path AS file, t.segment_start_ms AS at,
|
|
37
|
+
a.starred_at AS starredAt, a.rating AS rating
|
|
38
|
+
FROM track_annotation a JOIN track t ON t.id = a.track_id
|
|
39
|
+
JOIN file f ON f.id = t.file_id JOIN root r ON r.id = f.root_id
|
|
40
|
+
ORDER BY a.track_id`)
|
|
41
|
+
.all(),
|
|
42
|
+
albums: db
|
|
43
|
+
.prepare(`SELECT r.path AS root, a.rel_path AS rel, an.starred_at AS starredAt, an.rating AS rating
|
|
44
|
+
FROM album_annotation an JOIN album a ON a.id = an.album_id
|
|
45
|
+
JOIN root r ON r.id = a.root_id ORDER BY an.album_id`)
|
|
46
|
+
.all(),
|
|
47
|
+
artists: db
|
|
48
|
+
.prepare(`SELECT ar.name AS name, an.starred_at AS starredAt, an.rating AS rating
|
|
49
|
+
FROM artist_annotation an JOIN artist ar ON ar.id = an.artist_id ORDER BY an.artist_id`)
|
|
50
|
+
.all(),
|
|
51
|
+
},
|
|
52
|
+
bookmarks: db
|
|
53
|
+
.prepare(`SELECT r.path AS root, f.rel_path AS file, t.segment_start_ms AS at,
|
|
54
|
+
b.position_ms AS positionMs, b.comment AS comment,
|
|
55
|
+
b.created_at AS createdAt, b.changed_at AS changedAt
|
|
56
|
+
FROM bookmark b JOIN track t ON t.id = b.track_id
|
|
57
|
+
JOIN file f ON f.id = t.file_id JOIN root r ON r.id = f.root_id
|
|
58
|
+
ORDER BY b.track_id`)
|
|
59
|
+
.all(),
|
|
60
|
+
junkMarks: db
|
|
61
|
+
.prepare(`SELECT r.path AS root, m.rel_path AS rel, m.verdict AS verdict,
|
|
62
|
+
m.note AS note, m.marked_at AS markedAt
|
|
63
|
+
FROM junk_mark m JOIN root r ON r.id = m.root_id ORDER BY r.path, m.rel_path`)
|
|
64
|
+
.all(),
|
|
65
|
+
apiKeys: db
|
|
66
|
+
.prepare('SELECT label, secret, created_at AS createdAt, revoked_at AS revokedAt FROM api_key ORDER BY id')
|
|
67
|
+
.all(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Write the document back, by natural key, and say what it could not place.
|
|
72
|
+
*
|
|
73
|
+
* **A merge, not a replacement.** A restore is run on a library that already has
|
|
74
|
+
* something in it — that is the situation it exists for — and deleting what is
|
|
75
|
+
* there first would be this route deciding that the document is the truth. It
|
|
76
|
+
* adds what is missing and leaves what is not in the document alone.
|
|
77
|
+
*
|
|
78
|
+
* **Every entry that cannot be placed is counted and described.** A restore that
|
|
79
|
+
* silently dropped the third of a playlist whose files have moved would look
|
|
80
|
+
* exactly like one that worked, and the operator would find out by listening.
|
|
81
|
+
* `skipped` is where that loss is stated, and it names the key rather than a
|
|
82
|
+
* count of them.
|
|
83
|
+
*/
|
|
84
|
+
export function restoreState(db, doc) {
|
|
85
|
+
const placed = {
|
|
86
|
+
playlists: 0,
|
|
87
|
+
playlistEntries: 0,
|
|
88
|
+
trackStars: 0,
|
|
89
|
+
albumStars: 0,
|
|
90
|
+
artistStars: 0,
|
|
91
|
+
bookmarks: 0,
|
|
92
|
+
junkMarks: 0,
|
|
93
|
+
apiKeys: 0,
|
|
94
|
+
};
|
|
95
|
+
const skipped = [];
|
|
96
|
+
// **Prepared once, and not once per entry — this is the whole cost of a
|
|
97
|
+
// restore.** Measured on a copy of the live deployment with a document of
|
|
98
|
+
// 8.11 MB / 45 000 entries: preparing the statements inside the loops below
|
|
99
|
+
// was **2848 ms of the 3459 ms** the restore took, and the music port is
|
|
100
|
+
// frozen for the whole of it — 3.2 s during which no client is answered.
|
|
101
|
+
// Hoisted, the same restore measured **771 ms**.
|
|
102
|
+
const sql = {
|
|
103
|
+
playlistByName: db.prepare('SELECT id FROM playlist WHERE name = ?'),
|
|
104
|
+
insertPlaylist: db.prepare('INSERT INTO playlist (name, comment, public, created_at, changed_at) VALUES (?, ?, ?, ?, ?)'),
|
|
105
|
+
nextPosition: db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS next FROM playlist_track WHERE playlist_id = ?'),
|
|
106
|
+
insertEntry: db.prepare('INSERT OR IGNORE INTO playlist_track (playlist_id, position, track_id) VALUES (?, ?, ?)'),
|
|
107
|
+
// The join a whole file and a segment of one are told apart by. `IS` and not
|
|
108
|
+
// `=`: a whole file's key is `null`, and `segment_start_ms = NULL` is never
|
|
109
|
+
// true — it would have found nothing for every ordinary song.
|
|
110
|
+
findTrack: db.prepare(`SELECT t.id AS id FROM track t JOIN file f ON f.id = t.file_id JOIN root r ON r.id = f.root_id
|
|
111
|
+
WHERE r.path = ? AND f.rel_path = ? AND t.segment_start_ms IS ?`),
|
|
112
|
+
trackStar: db.prepare(`INSERT INTO track_annotation (track_id, starred_at, rating) VALUES (?, ?, ?)
|
|
113
|
+
ON CONFLICT (track_id) DO UPDATE SET starred_at = excluded.starred_at, rating = excluded.rating`),
|
|
114
|
+
albumStar: db.prepare(`INSERT INTO album_annotation (album_id, starred_at, rating) VALUES (?, ?, ?)
|
|
115
|
+
ON CONFLICT (album_id) DO UPDATE SET starred_at = excluded.starred_at, rating = excluded.rating`),
|
|
116
|
+
artistStar: db.prepare(`INSERT INTO artist_annotation (artist_id, starred_at, rating) VALUES (?, ?, ?)
|
|
117
|
+
ON CONFLICT (artist_id) DO UPDATE SET starred_at = excluded.starred_at, rating = excluded.rating`),
|
|
118
|
+
albumByPath: db.prepare('SELECT a.id FROM album a JOIN root r ON r.id = a.root_id WHERE r.path = ? AND a.rel_path = ?'),
|
|
119
|
+
artistByName: db.prepare('SELECT id FROM artist WHERE name = ?'),
|
|
120
|
+
bookmark: db.prepare(`INSERT INTO bookmark (track_id, position_ms, comment, created_at, changed_at) VALUES (?, ?, ?, ?, ?)
|
|
121
|
+
ON CONFLICT (track_id) DO UPDATE SET position_ms = excluded.position_ms, comment = excluded.comment,
|
|
122
|
+
changed_at = excluded.changed_at`),
|
|
123
|
+
rootByPath: db.prepare('SELECT id FROM root WHERE path = ?'),
|
|
124
|
+
junkMark: db.prepare(`INSERT INTO junk_mark (root_id, rel_path, verdict, note, marked_at) VALUES (?, ?, ?, ?, ?)
|
|
125
|
+
ON CONFLICT (root_id, rel_path) DO UPDATE SET verdict = excluded.verdict, note = excluded.note,
|
|
126
|
+
marked_at = excluded.marked_at`),
|
|
127
|
+
albumJunk: db.prepare("UPDATE album SET junk_reason = CASE WHEN ? = 'junk' THEN 'marked junk by hand' ELSE NULL END WHERE root_id = ? AND rel_path = ?"),
|
|
128
|
+
keyBySecret: db.prepare('SELECT id FROM api_key WHERE secret = ?'),
|
|
129
|
+
insertKey: db.prepare('INSERT INTO api_key (label, secret, created_at, revoked_at) VALUES (?, ?, ?, ?)'),
|
|
130
|
+
};
|
|
131
|
+
/** The track a key names, or nothing when this library does not have it. */
|
|
132
|
+
const resolveTrack = (key) => sql.findTrack.get(key.root, key.file, key.at)?.id ?? null;
|
|
133
|
+
db.exec('BEGIN IMMEDIATE');
|
|
134
|
+
try {
|
|
135
|
+
for (const list of doc.playlists ?? []) {
|
|
136
|
+
const existing = sql.playlistByName.get(list.name);
|
|
137
|
+
const id = existing?.id ??
|
|
138
|
+
Number(sql.insertPlaylist.run(list.name, list.comment, list.public ? 1 : 0, list.createdAt, list.changedAt)
|
|
139
|
+
.lastInsertRowid);
|
|
140
|
+
if (existing === undefined)
|
|
141
|
+
placed.playlists = (placed.playlists ?? 0) + 1;
|
|
142
|
+
let position = Number(sql.nextPosition.get(id).next);
|
|
143
|
+
for (const key of list.entries ?? []) {
|
|
144
|
+
const trackId = resolveTrack(key);
|
|
145
|
+
if (trackId === null) {
|
|
146
|
+
skipped.push({ what: `${list.name}: ${key.file}`, why: 'no such file (or segment) in this library' });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
sql.insertEntry.run(id, position, trackId);
|
|
150
|
+
position += 1;
|
|
151
|
+
placed.playlistEntries = (placed.playlistEntries ?? 0) + 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const star of doc.starred?.tracks ?? []) {
|
|
155
|
+
const trackId = resolveTrack(star);
|
|
156
|
+
if (trackId === null) {
|
|
157
|
+
skipped.push({ what: `star: ${star.file}`, why: 'no such file (or segment) in this library' });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
sql.trackStar.run(trackId, star.starredAt, star.rating);
|
|
161
|
+
placed.trackStars = (placed.trackStars ?? 0) + 1;
|
|
162
|
+
}
|
|
163
|
+
for (const star of doc.starred?.albums ?? []) {
|
|
164
|
+
const album = sql.albumByPath.get(star.root, star.rel);
|
|
165
|
+
if (album === undefined) {
|
|
166
|
+
skipped.push({ what: `star: ${star.rel}`, why: 'no such album (or root) in this library' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
sql.albumStar.run(album.id, star.starredAt, star.rating);
|
|
170
|
+
placed.albumStars = (placed.albumStars ?? 0) + 1;
|
|
171
|
+
}
|
|
172
|
+
for (const star of doc.starred?.artists ?? []) {
|
|
173
|
+
const artist = sql.artistByName.get(star.name);
|
|
174
|
+
if (artist === undefined) {
|
|
175
|
+
skipped.push({ what: `star: ${star.name}`, why: 'no such artist in this library' });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
sql.artistStar.run(artist.id, star.starredAt, star.rating);
|
|
179
|
+
placed.artistStars = (placed.artistStars ?? 0) + 1;
|
|
180
|
+
}
|
|
181
|
+
for (const mark of doc.bookmarks ?? []) {
|
|
182
|
+
const trackId = resolveTrack(mark);
|
|
183
|
+
if (trackId === null) {
|
|
184
|
+
skipped.push({ what: `bookmark: ${mark.file}`, why: 'no such file (or segment) in this library' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
sql.bookmark.run(trackId, mark.positionMs, mark.comment, mark.createdAt, mark.changedAt);
|
|
188
|
+
placed.bookmarks = (placed.bookmarks ?? 0) + 1;
|
|
189
|
+
}
|
|
190
|
+
for (const mark of doc.junkMarks ?? []) {
|
|
191
|
+
const root = sql.rootByPath.get(mark.root);
|
|
192
|
+
if (root === undefined) {
|
|
193
|
+
skipped.push({ what: `junk mark: ${mark.rel}`, why: 'the root it was made under is not configured here' });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
sql.junkMark.run(root.id, mark.rel, mark.verdict, mark.note, mark.markedAt);
|
|
197
|
+
// The album's own verdict is re-derived from the mark the same way `mark`
|
|
198
|
+
// does it, so a restored deployment hides what the old one hid.
|
|
199
|
+
sql.albumJunk.run(mark.verdict, root.id, mark.rel);
|
|
200
|
+
placed.junkMarks = (placed.junkMarks ?? 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
for (const key of doc.apiKeys ?? []) {
|
|
203
|
+
if (sql.keyBySecret.get(key.secret) !== undefined)
|
|
204
|
+
continue;
|
|
205
|
+
sql.insertKey.run(key.label, key.secret, key.createdAt, key.revokedAt);
|
|
206
|
+
placed.apiKeys = (placed.apiKeys ?? 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
db.exec('COMMIT');
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
try {
|
|
212
|
+
db.exec('ROLLBACK');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Already unwound; the original error is what matters.
|
|
216
|
+
}
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
return { placed, skipped };
|
|
220
|
+
}
|
|
221
|
+
/** Whether a parsed body is one of ours, before anything is written. */
|
|
222
|
+
export function isExport(value) {
|
|
223
|
+
if (value === null || typeof value !== 'object')
|
|
224
|
+
return false;
|
|
225
|
+
const doc = value;
|
|
226
|
+
return doc.funoteka === 'export' && typeof doc.version === 'number';
|
|
227
|
+
}
|