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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. 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
+ }