- Carry unmanaged stored config keys when saving, OAuth, and device auth - Add tests for credential carryover and cleared optional fields - Document carriedStoredConfig behavior in architecture docs
3789 lines
243 KiB
Markdown
3789 lines
243 KiB
Markdown
# Songhive Architecture
|
||
|
||
## System Overview
|
||
|
||
Songhive is a federated and self-hosted music sharing service. It uses
|
||
ActivityPub for federation (via [pubby](https://github.com/blacklight/pubby))
|
||
and is designed to interoperate with Mastodon-compatible clients and the
|
||
fediverse at large.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ External Services │
|
||
│ ┌──────────────┐ ┌────────────────┐ ┌─────────────────────────────────┐ │
|
||
│ │ MusicBrainz │ │ Cover Art │ │ Federated Instances (fediverse) │ │
|
||
│ │ (metadata + │ │ Archive (cover │ │ (ActivityPub inbox/outbox) │ │
|
||
│ │ MBID lookup)│ │ art images) │ └─────────────────────────────────┘ │
|
||
│ └──────────────┘ └────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Reverse Proxy (Nginx) │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────┼─────────────────┐
|
||
▼ ▼ ▼
|
||
┌─────────────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐
|
||
│ Frontend (Vue.js 3) │ │ Tornado Server │ │ Static/Media Files │
|
||
│ TypeScript + Vite │ │ (FastAPI ASGI) │ │ (Local filesystem / S3) │
|
||
└─────────────────────────┘ └─────────────────┘ └─────────────────────────────┘
|
||
│
|
||
┌───────────────────────┼──────────────────────┐
|
||
▼ ▼ ▼
|
||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||
│ PostgreSQL │ │ Redis │ │ Celery │
|
||
│ (Primary │ │ (Cache / │ │ (Workers) │
|
||
│ Database) │ │ Broker / │ │ │
|
||
│ │ │ Sessions) │ │ │
|
||
└─────────────┘ └─────────────┘ └─────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Backend Stack
|
||
|
||
| Component | Technology |
|
||
|----------------|----------------------------------------------------|
|
||
| API Framework | FastAPI (ASGI) |
|
||
| Server | Tornado (wrapping FastAPI via `a2wsgi`) |
|
||
| Fallback server| uvicorn (when `a2wsgi` is unavailable) |
|
||
| WebSockets | Tornado native WebSocket handlers |
|
||
| Data Models | Pydantic v2 (validation/serialization) |
|
||
| ORM | SQLAlchemy 2 (async, mapped columns) |
|
||
| Task Queue | Celery + Redis |
|
||
| Cache/Sessions | Redis |
|
||
| Federation | pubby (ActivityPub library) |
|
||
| Auth | JWT access tokens + opaque refresh tokens (Redis) |
|
||
| OAuth2 | authlib (OAuth2 provider for third-party apps) |
|
||
| Email | SMTP (via Python `smtplib`) |
|
||
| Metadata | mutagen (tag reading), MusicBrainz API |
|
||
| Transcoding | ffmpeg (system dependency) |
|
||
| Rate limiting | Redis sliding-window (per-IP and per-user) |
|
||
|
||
---
|
||
|
||
## Application Structure
|
||
|
||
```
|
||
songhive/
|
||
├── __init__.py
|
||
├── __main__.py
|
||
├── app.py # Entry point: Tornado+FastAPI bootstrap; uvicorn fallback
|
||
├── version.py
|
||
├── cli/ # Admin commands (init-db, migrate, create-user, etc.)
|
||
│ └── admin.py
|
||
├── config/ # Configuration management
|
||
│ ├── schema.py # Pydantic BaseSettings model (all subsections)
|
||
│ └── loader.py # TOML + env vars + CLI argument loading
|
||
├── api/ # FastAPI application
|
||
│ ├── app.py # FastAPI factory: middleware, route registration, federation setup
|
||
│ ├── _common.py # Shared helpers (pagination, client IP, etc.)
|
||
│ ├── cookies.py # Set/clear HttpOnly auth cookies + double-submit CSRF cookie
|
||
│ ├── deps.py # Dependency injection (DB session, current user, config, Redis, storage)
|
||
│ ├── errors.py # RFC 7807 problem-detail exception handlers
|
||
│ ├── semantic_meta.py # OpenGraph/rel="tag" <head> tag builders + SPA shell injection
|
||
│ ├── routes/ # Route modules (one file per resource)
|
||
│ │ ├── auth.py # Login, registration, token refresh, password reset
|
||
│ │ ├── sessions.py # List and revoke active refresh-token sessions
|
||
│ │ ├── users.py # Public user profiles, directory, per-user activity feed; authenticated profile updates
|
||
│ │ ├── profile_pages.py # Browser SPA profile routes, rel="me" link injection, ActivityPub negotiation
|
||
│ │ ├── activities.py # Activity interactions (like, edit, delete, …)
|
||
│ │ ├── statuses.py # Standalone status composer endpoint (POST /statuses)
|
||
│ │ ├── artists.py
|
||
│ │ ├── albums.py
|
||
│ │ ├── tracks.py
|
||
│ │ ├── playlists.py
|
||
│ │ ├── libraries.py
|
||
│ │ ├── favorites.py
|
||
│ │ ├── tags.py # Global tag browsing and admin deletion
|
||
│ │ ├── genres.py # Global genre browsing and admin deletion
|
||
│ │ ├── history.py # Listening history
|
||
│ │ ├── stats.py # Personal listening statistics
|
||
│ │ ├── radios.py # Dynamic radio generation
|
||
│ │ ├── files.py # Generic file upload/download (StoredFile)
|
||
│ │ ├── downloads.py # Bulk-download archive requests (ZIP), status, file download, clear
|
||
│ │ ├── shares.py # Share grants (owner→specific user) + /shares/mine listing
|
||
│ │ ├── share_urls.py # Share URL tokens (revocable short links)
|
||
│ │ ├── share.py # Public token resolver (redirects + sets cookie)
|
||
│ │ ├── reports.py # Content moderation reports + admin review
|
||
│ │ ├── feeds.py # RSS/Atom feeds under /feeds (users, entities, tags, genres)
|
||
│ │ ├── federation.py # ActivityPub object endpoints (tracks, objects, federated music entities) + WebFinger
|
||
│ │ ├── remote.py # Explicit remote lookup/dereference + cached remote objects
|
||
│ │ ├── admin.py # Admin endpoints (settings, stats, user management)
|
||
│ │ ├── external_libraries.py # User external library CRUD, sync, tracks
|
||
│ │ └── admin_external_libraries.py # Admin external library management
|
||
│ └── middleware/
|
||
│ ├── auth.py # JWT decode middleware + access-token helpers
|
||
│ ├── csrf.py # Double-submit CSRF check for cookie-authenticated unsafe requests
|
||
│ ├── head.py # HEAD answered as bodyless GET (crawler probes, e.g. og:image)
|
||
│ ├── media_cors.py # Wildcard CORS on read-only media endpoints (federation embeds)
|
||
│ ├── rate_limit.py # Redis sliding-window rate limiting (IP / user)
|
||
│ └── proxy.py # X-Forwarded-Proto scheme handling behind reverse proxies
|
||
├── migrations/ # Alembic database migrations
|
||
│ ├── env.py # Migration environment (imports all models)
|
||
│ ├── script.py.mako # Template for generated revisions
|
||
│ └── versions/ # Revision scripts
|
||
├── models/ # SQLAlchemy mapped models + shared enums
|
||
│ ├── base.py # DeclarativeBase, UUID PK, timestamps, async session factory
|
||
│ ├── _enums.py # Visibility enum (private / mentioned / local / followers / public)
|
||
│ ├── activity.py # Activity, ActivityMention, ActivityTarget (federation interaction layer)
|
||
│ ├── preview_card.py # PreviewCard — per-URL OpenGraph/<title>/domain card cache
|
||
│ ├── remote_object.py # RemoteObject — cached remote AP objects + normalized fields
|
||
│ ├── user.py # User (roles: user / moderator / admin; federation fields)
|
||
│ ├── user_link.py # Profile links (validated URL list)
|
||
│ ├── invite.py # Invite codes (max_uses, expiry)
|
||
│ ├── artist.py
|
||
│ ├── album.py
|
||
│ ├── track.py
|
||
│ ├── upload.py # Raw uploaded file reference
|
||
│ ├── stored_file.py # Content-addressable file (SHA-256, visibility, owner)
|
||
│ ├── transcoded_file.py # Transcode cache: (track_id, format, bitrate) → StoredFile
|
||
│ ├── download.py # DownloadArchive — async bulk-download ZIP request (items snapshot, item_errors)
|
||
│ ├── library.py
|
||
│ ├── library_track.py # Library ↔ Track join table
|
||
│ ├── playlist.py
|
||
│ ├── favorite.py
|
||
│ ├── follow.py # Outbound follow relationships (local users → local/remote actors)
|
||
│ ├── genre.py # Genre and GenreTrack/GenreAlbum associations
|
||
│ ├── history.py # Listening history entries
|
||
│ ├── radio.py
|
||
│ ├── share_grant.py # Per-user access grant for a specific item
|
||
│ ├── share_token.py # Revocable short-link token (stores SHA-256 hash only)
|
||
│ ├── report.py # Content moderation report
|
||
│ ├── oauth_client.py # Registered OAuth2 clients
|
||
│ ├── audit_log.py # Admin/security audit trail
|
||
│ ├── external_library.py # External library adapter instance
|
||
│ ├── external_sync_run.py # External library sync history
|
||
│ ├── external_track.py # Track discovered through an external provider
|
||
│ ├── output_stream.py # Server-side audio output (encrypted provider config)
|
||
│ ├── playback_session.py # Persisted playback control plane for server outputs
|
||
│ └── setting.py # Runtime-editable instance settings (key/JSON-value)
|
||
├── services/ # Business logic layer
|
||
│ ├── acl.py # Three-level visibility + share-grant + share-token ACL
|
||
│ ├── activities.py # Activity domain service (entity resolution, creation, interactions)
|
||
│ ├── auth.py # User lookup, password hashing, session helpers
|
||
│ ├── audit.py # Audit log helpers
|
||
│ ├── deletion.py # Cascade deletion + activity retraction fan-out
|
||
│ ├── downloads.py # Archive item resolution (ACL), ZIP materialization, name sanitization
|
||
│ ├── email.py # SMTP email (verification, password reset)
|
||
│ ├── federation.py # Actor provisioning, domain allow/block, inbox dispatch
|
||
│ ├── feeds.py # RSS 2.0/Atom feed documents + per-entity feed queries
|
||
│ ├── follows.py # Outbound follow/unfollow, decision folding, follow listings
|
||
│ ├── genres.py # Genre validation, association and listing
|
||
│ ├── import_.py # Import pipeline orchestration
|
||
│ ├── listening_stats.py # Per-user listening stats aggregation (top-N, histograms, clock)
|
||
│ ├── mentions.py # @handle extraction, local/WebFinger resolution, safe HTML rendering
|
||
│ ├── remote_content.py # Remote lookup parser, actor/object dereference + cache, cached search
|
||
│ ├── metadata.py # Tag extraction coordination
|
||
│ ├── music.py # Music library helpers
|
||
│ ├── musicbrainz.py # MusicBrainz + Cover Art Archive enrichment (async httpx)
|
||
│ ├── outputs.py # Output CRUD, config encryption, mount resolution
|
||
│ ├── playback.py # PlaybackSession state machine + worker commands
|
||
│ ├── redis.py # Redis client lifecycle
|
||
│ ├── reports.py # Content report CRUD
|
||
│ ├── secrets.py # Config encryption, decryption, and secret redaction
|
||
│ ├── settings.py # Instance settings with Redis cache + config overlay
|
||
│ ├── sharing.py # Share-grant and share-token CRUD
|
||
│ ├── stats.py # Admin dashboard statistics
|
||
│ ├── storage.py # StorageService facade (delegates to storage backend)
|
||
│ └── streaming.py # Track file resolution, transcode cache, history recording
|
||
├── external/ # External library adapter interface and registry
|
||
│ ├── base.py # BaseExternalLibraryAdapter protocol
|
||
│ ├── _fake.py # In-memory fake adapter for tests
|
||
│ ├── _local.py # Local filesystem adapter
|
||
│ ├── watchdog.py # Filesystem watchdog for local libraries
|
||
│ ├── registry.py # Provider-type registry
|
||
│ └── types.py # Adapter dataclasses (ItemRef, TrackMetadata, etc.)
|
||
├── federation/ # ActivityPub per-user federation
|
||
│ ├── _common.py # URL builders
|
||
│ ├── fetch.py # SSRF-guarded remote fetch (DNS/IP checks, redirect revalidation) + guarded binary download
|
||
│ ├── actors.py # Actor document generation, federation storage helpers
|
||
│ ├── activities.py # Activity creation (Create, Update, Delete, etc.)
|
||
│ ├── doc_cache.py # Shared pubby.cache document cache: tuple-key schema + after-commit invalidation helpers
|
||
│ ├── incoming.py # Materialize inbound remote replies into Activity rows
|
||
│ ├── notifications.py # Inbox recipient resolution + notification hooks
|
||
│ ├── serializers.py # Track → ActivityPub Audio object mapping
|
||
│ └── storage.py # pubby storage adapter (SQLAlchemy-backed)
|
||
├── users/ # User management
|
||
│ ├── manager.py # User CRUD, password management
|
||
│ ├── invites.py # Invite-code creation, validation, consumption
|
||
│ ├── oauth.py # OAuth2 provider setup (authlib)
|
||
│ └── tokens.py # JWT access + opaque refresh token issuance/rotation/revocation, session listing
|
||
├── music/ # Music domain logic
|
||
│ ├── importer.py # File importer: save → extract tags → link track
|
||
│ └── metadata.py # Tag reading (mutagen), field normalization
|
||
├── streaming/ # Audio streaming
|
||
│ ├── handler.py # Tornado streaming handler (range requests, send-file)
|
||
│ ├── mount.py # Tornado listener handler for native HTTP mounts
|
||
│ └── transcoder.py # ffmpeg wrapper: MP3, OGG, FLAC, AAC, Opus
|
||
├── streams/ # Server-side audio outputs
|
||
│ ├── base.py # AudioOutput provider base class (fields, redaction)
|
||
│ ├── driver.py # OutputDriver runtime interface (start/pause/seek/events)
|
||
│ ├── registry.py # provider_type → AudioOutput registry
|
||
│ ├── icecast.py # Icecast provider: ffmpeg → icecast:// pipeline
|
||
│ ├── http.py # Native HTTP provider: ffmpeg → Redis Stream fan-out
|
||
│ ├── snapcast.py # Snapcast provider: ffmpeg PCM → snapserver FIFO/TCP
|
||
│ ├── fake.py # In-memory provider for tests
|
||
│ ├── types.py # AudioSource and related dataclasses
|
||
│ └── worker.py # `songhive stream-worker` session driver loop
|
||
├── storage/ # Media storage backends
|
||
│ ├── base.py # Abstract StorageBackend interface + FileSizeLimitExceededError
|
||
│ ├── exc.py # Storage-layer exceptions
|
||
│ ├── local.py # Local filesystem backend
|
||
│ └── s3.py # S3-compatible object storage backend
|
||
├── tasks/ # Celery task definitions
|
||
│ ├── celery.py # Celery app factory (crontab parser, config loading)
|
||
│ ├── import_.py # Import pipeline tasks
|
||
│ ├── federation.py # Activity delivery + inbox processing tasks
|
||
│ ├── transcoding.py # Pre-transcoding tasks
|
||
│ ├── email.py # Email delivery tasks
|
||
│ ├── musicbrainz.py # MusicBrainz metadata + Cover Art Archive enrichment
|
||
│ ├── images.py # Artist image + album cover enrichment
|
||
│ ├── external_libraries.py # External library sync task
|
||
│ ├── preview_cards.py # Link-preview fetch + per-URL cache task
|
||
│ ├── downloads.py # Async ZIP archive builds (Redis concurrency cap) + retention cleanup (scheduled)
|
||
│ └── storage.py # Orphaned-file cleanup (scheduled via crontab)
|
||
├── ws/ # WebSocket support
|
||
│ └── events.py # Tornado WebSocket handler (JWT auth, CORS origin check)
|
||
└── cli/ # CLI commands
|
||
└── admin.py # Admin commands (create-user, provision-federation-keys, etc.)
|
||
```
|
||
|
||
---
|
||
|
||
## Server Architecture: Tornado + FastAPI
|
||
|
||
The server uses Tornado as the top-level HTTP server. The FastAPI ASGI app is
|
||
bridged via `a2wsgi` (ASGI→WSGI) and wrapped in Tornado's `WSGIContainer`.
|
||
When `a2wsgi` is not installed the server falls back to `uvicorn` (pure ASGI,
|
||
loses native Tornado handlers).
|
||
|
||
**Why Tornado as the outer server:**
|
||
|
||
- **Native WebSockets** — the real-time events endpoint is a proper Tornado
|
||
`WebSocketHandler`, avoiding ASGI WebSocket complexity.
|
||
- **Streaming handler** — audio files are streamed via a dedicated Tornado
|
||
handler with native range-request support.
|
||
- **Signal handling / graceful shutdown** — Tornado's `IOLoop` controls the
|
||
process lifecycle.
|
||
|
||
```python
|
||
# Simplified bootstrap (songhive/app.py)
|
||
from a2wsgi import ASGIMiddleware
|
||
from tornado.web import Application, FallbackHandler
|
||
from tornado.wsgi import WSGIContainer
|
||
from tornado.httpserver import HTTPServer
|
||
|
||
from songhive.api.app import create_app
|
||
from songhive.ws.events import EventWebSocket
|
||
from songhive.streaming.handler import StreamHandler
|
||
from songhive.streaming.mount import StreamMountHandler
|
||
|
||
fastapi_app = create_app(config)
|
||
wsgi_app = ASGIMiddleware(fastapi_app)
|
||
container = WSGIContainer(wsgi_app, executor=ThreadPoolExecutor(...))
|
||
|
||
tornado_app = Application([
|
||
(r"/ws/events", EventWebSocket),
|
||
(r"/ws/", EventWebSocket),
|
||
(r"/api/v1/stream/(?P<track_id>[^/]+)", StreamHandler),
|
||
(r"/streams/(?P<mount>[^/]+)", StreamMountHandler),
|
||
(r".*", FallbackHandler, {"fallback": container}),
|
||
])
|
||
|
||
server = HTTPServer(tornado_app)
|
||
server.listen(config.server.port)
|
||
```
|
||
|
||
> **Note:** `WSGIContainer` must be given an explicit `ThreadPoolExecutor`.
|
||
> Without one (Tornado < 7 default) the WSGI app runs on the Tornado event
|
||
> loop thread, serializing every request — and any server-side remote fetch
|
||
> that triggers a synchronous call-back to this instance (e.g. a remote
|
||
> ActivityPub server resolving our `keyId` for HTTP signature verification)
|
||
> deadlocks, since the inbound request can never be served while the loop is
|
||
> busy inside the outbound lookup.
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
Configuration is loaded with the following priority (highest first):
|
||
|
||
1. Environment variables (prefixed `SONGHIVE_`, nested with `__`)
|
||
2. CLI arguments
|
||
3. `config.toml` (searched at the path given by `--config` or `SONGHIVE_CONFIG`,
|
||
then `./config.toml`, then `$XDG_CONFIG_HOME/songhive/config.toml` or
|
||
`~/.config/songhive/config.toml`, and finally `/etc/songhive/config.toml`)
|
||
4. Field defaults
|
||
|
||
The root schema is `SonghiveConfig` (a Pydantic `BaseSettings`), composed of
|
||
these subsections:
|
||
|
||
| Section | Key settings |
|
||
|----------------|---------------------------------------------------------------|
|
||
| `server` | host, port, num_workers, debug, cors_origins |
|
||
| `database` | url (asyncpg), pool_size, max_overflow, pool_timeout, pool_recycle, pool_pre_ping |
|
||
| `redis` | url |
|
||
| `celery` | broker_url, result_backend, cleanup_orphaned_files_schedule |
|
||
| `storage` | backend (local/s3), local_path, s3_*, cdn_prefix, max_upload_size, upload_quota |
|
||
| `federation` | enabled, instance_domain, instance_name, contact_name/contact_email/contact_url, private_key_path, allow/block lists, remote_search_access, fetch_timeout_seconds, remote_activity_retention_days, remote_activity_prune_schedule, document_cache_ttl_seconds |
|
||
| `auth` | registration_mode, secret_key, token TTLs, rate_limit, trusted_proxy_hops, cookie_secure, cookie_samesite, cookie_domain |
|
||
| `email` | smtp_host, smtp_port, smtp_user, from_address, tls settings |
|
||
| `musicbrainz` | enabled, user_agent, cover_art, artist_image settings |
|
||
| `notifications`| retention_days, purge_hour, digest_hour |
|
||
| `imports` | scan_roots, bulk_import_sync_threshold |
|
||
| `downloads` | enabled, max_items, max_concurrent_archives, max_active_per_user, retention_hours, stale_run_hours, fetch_attempts, fetch_backoff_seconds, fetch_timeout_seconds, max_item_bytes |
|
||
| `streaming` | max_bitrate, max_bitrate_by_role, default_bitrate, chunk_size, transcode_cache_enabled |
|
||
| `streams` | enabled, allow_user_created_outputs, allowed/denied_user_providers, allowed_output_hosts, icecast_ffmpeg_path, worker timings, idle timeout, http_stream_* |
|
||
|
||
Runtime-editable overrides (instance settings stored in the `settings` DB
|
||
table, cached in Redis) are applied over the file-based config at startup and
|
||
can be changed via the admin API without a restart.
|
||
|
||
---
|
||
|
||
## Data Model (Core Entities)
|
||
|
||
```
|
||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
||
│ Artist │────▶│ Album │────▶│ Track │────▶│ StoredFile │
|
||
└─────────────┘ └─────────────┘ └─────────────┘ │ (audio_file) │
|
||
│ └──────────────┘
|
||
│
|
||
▼
|
||
┌─────────────┐ ┌──────────────┐
|
||
│ Upload │────▶│ StoredFile │
|
||
│ (raw upload)│ │ (upload file)│
|
||
└─────────────┘ └──────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────┐
|
||
│ TranscodedFile │
|
||
│ (track, format, │
|
||
│ bitrate) │
|
||
└──────────────────┘
|
||
|
||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||
│ User │────▶│ Library │────▶│ LibraryTrack│
|
||
│ (roles: │ └─────────────┘ └─────────────┘
|
||
│ user / │
|
||
│ moderator /│ ┌─────────────┐ ┌─────────────┐
|
||
│ admin) │────▶│ Playlist │────▶│ Track │
|
||
│ │ └─────────────┘ └─────────────┘
|
||
│ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐
|
||
│ │────▶│ ShareGrant │────▶│ (any item) │
|
||
│ │ └─────────────┘ └─────────────┘
|
||
│ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐
|
||
│ │────▶│ ShareToken │────▶│ (any item) │
|
||
│ │ └─────────────┘ └─────────────┘
|
||
│ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐
|
||
│ │────▶│ Invite │ │ UserLink │
|
||
│ │ └─────────────┘ └─────────────┘
|
||
└─────────────┘
|
||
```
|
||
|
||
**Visibility levels** (`Visibility` enum) are ordered from most to least
|
||
restrictive: `private < mentioned < local < followers < public`
|
||
(`Visibility.rank`, `Visibility.can_contain`). Entity visibility currently
|
||
uses `private`, `local`, and `public` (applies to tracks, albums, artists,
|
||
libraries, stored files):
|
||
|
||
- `private` — visible only to the owner (and users with a `ShareGrant`)
|
||
- `local` — visible to authenticated users on the same instance
|
||
- `public` — visible to everyone including federated instances
|
||
|
||
The `mentioned` and `followers` levels exist for the activities layer
|
||
(`Activity.visibility`) and are not yet meaningful for entity ACLs.
|
||
|
||
### Activities
|
||
|
||
`Activity` records federation-relevant events (`create`, `announce`, `like`,
|
||
`reply`, `quote`, `mention`, `update`, `delete`, `webmention`) attached to an
|
||
entity through `(entity_type, entity_id)` — where `entity_type` is one of
|
||
`track`, `album`, `artist`, `playlist`, `library`, `user` (the `user` entity
|
||
hosts standalone statuses posted through `POST /api/v1/statuses`), `remote`
|
||
(remote objects dereferenced on demand — see below). Each row
|
||
tracks its origin via `source_type` (`local` or a remote source),
|
||
`source_actor`, and `source_id` (unique per source), with `local_object_id`
|
||
as an optional canonical local identifier. Activities support threading
|
||
through `in_reply_to_activity_id`, arbitrary JSON `payload`s, source text vs
|
||
rendered content (`content_source` / `content` / `content_type` — plain text
|
||
or Markdown), an optional BCP-47 `language` tag mirrored into the object's
|
||
`contentMap`, and soft deletion (`deleted_at`, `retracted`). An activity's
|
||
`visibility` must not exceed its parent entity's visibility
|
||
(`Visibility.can_contain`).
|
||
|
||
`ActivityTag` rows link `Activity` and `Tag`, populated whenever an activity
|
||
contains hashtags (`#tag`) in its `content_source`/`content`. They enable
|
||
`GET /api/v1/tags/{tag}/activities`, which returns tag-matching activities
|
||
subject to the same visibility and entity ACL rules as other activity feeds.
|
||
The same rows are folded into the tag listing queries in
|
||
`services/tags.py`, so `GET /api/v1/tags/` and `GET /api/v1/tags/{tag}`
|
||
also surface hashtags that appear only on activities (as `activity` items),
|
||
counted by `published_at` and filtered by activity visibility plus the
|
||
containing entity's ACL.
|
||
Remote ActivityPub content is processed by Pubby and is mostly not
|
||
materialized as local `Activity` rows — the exception is inbound `Create`
|
||
replies, which `federation/incoming.py` stores as `source_type="remote"`
|
||
rows (see below) so their hashtags and mentions are associated too. Other
|
||
remote content stays interaction-only, so hashtags from remote posts cannot
|
||
be associated on the Songhive side unless Pubby stores or forwards them.
|
||
`ActivityMention` rows capture `@handle` mentions embedded in content, with
|
||
optional `actor_url` / `user_id` resolution and a `notified_at` marker.
|
||
`services/mentions.py` implements the mention pipeline: `MENTION_REGEX`
|
||
extracts `@user` and `@user@domain` handles, `resolve_mentions` resolves bare
|
||
handles against the local `users` table (case-insensitive, active users only)
|
||
and remote handles through `pubby.resolve_actor_url` (run in a thread, with
|
||
Pubby's `https://{domain}/@{username}` fallback on lookup failure). Remote
|
||
resolution is gated on `federation.enabled`, requires a dotted domain, and
|
||
drops handles on blocked or non-allowed instances via
|
||
`services/federation.is_domain_blocked`; `@user@domain` handles naming the
|
||
local instance resolve locally instead. `render_mentions` builds safe HTML —
|
||
resolved handles become anchors via `pubby.render_link_anchor`, surrounding
|
||
text is escaped and linkified by `pubby.render_post_html` (which also
|
||
converts newlines to `<br>`, since remote servers render `content`/`summary`
|
||
as HTML and would collapse literal newlines) — and
|
||
`process_mentions` is the single entry point returning resolved mentions,
|
||
rendered HTML, tags, and ActivityPub `Mention`/`Hashtag` tags. It accepts a
|
||
`content_type`: `text/plain` uses the escaped plain-text renderer described
|
||
above, while `text/markdown` (`render_mentions_markdown`, the default for new
|
||
statuses) runs the source through `mistune` — with raw HTML escaped, bare
|
||
URLs linkified, `javascript:`-style link targets neutralized — before the
|
||
same mention/hashtag linkification applies; handles inside code spans are
|
||
left untouched.
|
||
`ActivityTarget` rows track per-inbox outbound delivery state (`pending`,
|
||
`sent`, `failed`, `skipped`) with `attempts` / `last_error` /
|
||
`last_attempt_at` bookkeeping. Tracks already published to the fediverse
|
||
(`federation_object_id` set) are backfilled as `create` activities by
|
||
migration `4adb5fbea9d6`.
|
||
|
||
Preview cards attach link-preview metadata to activities: when a post is
|
||
created locally (`create_status`, `reply_to_activity`, `quote_activity`,
|
||
`record_track_publication`) or materialized from a remote `Create`,
|
||
`schedule_preview_card_fetch` enqueues `tasks/preview_cards.py`'s
|
||
`fetch_preview_card`, which runs `services/preview_cards.py`'s
|
||
`process_activity_preview_card`. The service picks the first *pure* URL —
|
||
Mastodon-style — from the raw `content_source` of local posts (rendered
|
||
mention/hashtag anchors never appear there) or from the anchors of the
|
||
remote HTML `content`, skipping `mention`/`hashtag`/`u-url` classes and
|
||
`rel="tag"` links, then fetches OpenGraph metadata with `<title>` and
|
||
finally the URL's domain as fallbacks. Fetched cards live in the
|
||
`preview_cards` table keyed by normalized URL and are shared across
|
||
activities; a cached card is reused while fresher than
|
||
`PREVIEW_CARD_MAX_AGE` (24h) and re-fetched at post time otherwise, so
|
||
views never trigger network fetches. Activities whose object carries
|
||
attachments get no card, and edits re-run the pipeline (`force=True`) so a
|
||
stale link is refreshed or cleared. Fetches are guarded against SSRF —
|
||
only `http(s)` URLs resolving to globally routable addresses are
|
||
requested, redirects are re-validated per hop, and bodies are capped at
|
||
`MAX_DOCUMENT_BYTES` (1 MiB). The pipeline is gated by the instance-level
|
||
`preview_cards_enabled` runtime setting (admin UI, default on) and by the
|
||
per-user `preview_cards_enabled` preference (profile form, default on);
|
||
either opting out skips the enqueue, and the task re-checks both gates so
|
||
rows created before a change are honoured. `ActivityResponse.preview_card`
|
||
serializes the linked card and `ActivityCard.vue` renders it below the
|
||
post content.
|
||
|
||
`services/activities.py` is the domain entry point: `resolve_entity` maps an
|
||
`(entity_type, entity_id)` pair to its model row, and
|
||
`create_local_activity` validates the entity (404), enforces
|
||
`Visibility.can_contain` against the entity (422 — entities without a
|
||
visibility column, currently `Artist`, act as public containers), checks
|
||
`acl.can_manage` (403), then persists the activity plus any pre-resolved
|
||
`ActivityMention` rows. Local source identity reuses the author's
|
||
`actor_url`, falling back to a `urn:songhive:user:{username}` URN when the
|
||
user has no provisioned federation identity; `source_id` embeds the same
|
||
UUID as `local_object_id` (`{actor}/objects/{uuid}`) so section-9 object
|
||
routes can resolve it.
|
||
|
||
`can_view_activity` decides who may see an activity: the containing entity
|
||
must pass `acl.can_access`, and the activity's own `visibility` applies on
|
||
top (`public` follows the entity check; `local`/`followers` require an
|
||
authenticated user; `mentioned` requires the owner or a mentioned user;
|
||
`private` is owner-only; retracted activities are never viewable). Admins
|
||
get no bypass — `mentioned` and `private` activities stay confined to
|
||
their audience for every viewer. The same rules are enforced in SQL by
|
||
`_activity_visibility_filter` in `services/acl.py` (feed/tag/reply
|
||
listings) and in
|
||
`reply_count` computation, so restricted replies neither render nor leak
|
||
through counters for viewers outside their audience. Interactions are layered on top of `create_local_activity`:
|
||
`like_activity` records an idempotent `like` (400 on a duplicate, 404 on a
|
||
retracted target) that inherits the target's visibility and stores a
|
||
`federation/activities.create_like_activity` `Like` payload whose `to`/`cc`
|
||
come from `activity_audience`. `boost_activity` mirrors it as an
|
||
`announce` activity carrying an `Announce` payload
|
||
(`federation/activities.create_announce_activity`, a thin wrapper over
|
||
pubby's `build_announce_activity`) whose `object` is the target's
|
||
`source_id`; duplicates are rejected the same way. `reply_to_activity`
|
||
stores a `reply` activity whose payload is a `Create(Note)` with
|
||
`inReplyTo` set to the target's `source_id` — the reply reuses the status
|
||
composition pipeline (`process_mentions`, media/track attachment
|
||
resolution, `contentMap` language tagging) and its requested visibility
|
||
may never exceed the target's (`Visibility.can_contain`) or the entity's.
|
||
The replied-to author is always addressed: a local owner gets an
|
||
`ActivityMention` row plus a `reply` notification (`_notify_reply`), a
|
||
remote author gets a `Mention` tag so `resolve_audience` delivers the
|
||
reply to their inbox. Interactions skip the `can_manage` gate
|
||
(`require_manage=False`) — view access on the target is enough — while
|
||
content-producing activity types still require manage rights. The
|
||
`POST /api/v1/activities/{id}/like`, `/{id}/boost`, `/{id}/reply` and
|
||
`/{id}/quote`
|
||
endpoints perform the 404/403/400 (or 422 for invalid replies) checks,
|
||
provision the author's actor keys (`ensure_user_actor`), commit, then fan
|
||
out: likes and boosts share `_fan_out_reaction_activity` (exposed as
|
||
`fan_out_like_activity`/`fan_out_boost_activity`), which — for remote
|
||
targets — resolves the reacted author's inbox via
|
||
`services/federation.resolve_actor_inbox` (the `federation_actor_cache`
|
||
table first, then a signed actor-document fetch, preferring `sharedInbox`)
|
||
and hands it to `fan_out_activity` alongside the reaction's own audience;
|
||
replies fan out inline through `fan_out_activity`, best-effort. Quotes
|
||
(`quote_activity`) work like replies — a `Create(Note)` row linked to the
|
||
quoted activity through `in_reply_to_activity_id` — but the note carries
|
||
the FEP-0449 `quote` field plus the `quoteUri`/`quoteUrl` forms Fedibird,
|
||
Akkoma and Mastodon read and Misskey's `_misskey_quote` (never
|
||
`inReplyTo`: a quote is not a reply), the quoted author is always
|
||
addressed via a `Mention` tag — and `activity_audience` puts mentioned
|
||
actors in `to` for public and followers posts, matching how Mastodon and
|
||
Akkoma address their own — and FEP-044f authorization
|
||
is negotiated: quoting a local user's post self-issues the
|
||
`QuoteAuthorization` their auto-approval would grant (stored so it
|
||
dereferences, stamped as `quoteAuthorization` on the note), while quoting
|
||
a remote post delivers a `QuoteRequest` — quoting `Note` as `instrument`,
|
||
quoted object as `object` — to the remote author's inbox
|
||
(`_deliver_quote_request`, sent before the `Create` fan-out so the
|
||
authorization flow starts first). The remote `Accept` answering it stamps the
|
||
issued authorization id onto the stored quote (see inbound
|
||
materialization below). `DELETE /api/v1/activities/{id}/like` and
|
||
`/{id}/boost` retract the
|
||
caller's reaction: `unreact_activity` soft-deletes the like/announce row
|
||
(so the target can be reacted to again), removes the notification it
|
||
produced, and returns the retracted activity; `fan_out_unreaction_activity`
|
||
then delivers an `Undo` wrapping the originally federated `Like`/`Announce`
|
||
(`federation/activities.create_undo_activity`, over pubby's
|
||
`build_undo_activity`) to exactly the inboxes recorded as `sent` for the
|
||
reaction. Unreacting a target without a live reaction returns 404.
|
||
|
||
Every `ActivityResponse` carries interaction state resolved by
|
||
`resolve_interaction_summaries`: `like_count` and `boost_count` combine
|
||
direct local `Activity` rows (interactions stored with
|
||
`in_reply_to_activity_id` pointing at the target) with confirmed remote
|
||
interactions recorded in Pubby's `federation_interactions` storage against
|
||
the activity's `source_id` (`_remote_interactions`, thread-offloaded and
|
||
best-effort). `reply_count` instead covers the whole sub-thread: every
|
||
non-deleted `reply` descendant reachable through the `in_reply_to` chain —
|
||
computed with the `_activity_descendants_cte` recursive CTE — plus remote
|
||
replies targeting any node in it, including remote replies to remote
|
||
replies reached breadth-first through `object_id` chains
|
||
(`_remote_thread_interactions`). `quote_count` is flat — quotes attach to
|
||
the quoted post itself, never to each other — and counts direct
|
||
`quote`-type children (local quotes and materialized remote ones) plus
|
||
confirmed remote `QUOTE` interactions, deduplicated on `object_id`.
|
||
Remote replies already materialized into
|
||
`Activity` rows are skipped so a reply backed by both a row and a stored
|
||
interaction counts once. Descendants the requester may not see
|
||
(`mentioned` replies naming someone else, `private` rows owned by another
|
||
user) are not counted, though traversal still crosses them to reach their
|
||
visible children. `liked`/`boosted` report the requester's
|
||
own live like/announce rows, and `can_interact` is false for activity types
|
||
that cannot themselves be reacted to (`like`, `announce`, `delete`).
|
||
`ActivityResponse` also carries `object_url`/`object_type`, resolved from
|
||
the payload's `object`: `Create`-style activities expose the embedded
|
||
document's `id`/`type`, while `Like`/`Announce` payloads reference the
|
||
reacted object as a bare id — which is exactly the target a reaction card
|
||
should link to — and expose no `object_type`. `PATCH` rejects like and
|
||
announce activities (422): a reaction's object is a bare reference and
|
||
its visibility is inherited, so there is nothing editable. `GET
|
||
/api/v1/activities/{id}/likes` and `/{id}/boosts` return the
|
||
known interactors — local users resolved to profiles, remote actors from
|
||
Pubby's interaction records — via `list_activity_interactors`, and
|
||
`GET /api/v1/activities/{id}/replies` returns the known replies via
|
||
`list_activity_replies`: visibility-filtered local `reply` activities —
|
||
every descendant in the thread, not just direct children — serialized as
|
||
full `ActivityResponse`s plus remote replies rebuilt from the `raw_object`
|
||
metadata the inbox processor stored, each carrying `in_reply_to` (the
|
||
replied-to object id) so clients can regroup the flat list into threads.
|
||
Replies already materialized into `Activity` rows are filtered out of the
|
||
remote list so they render only as full cards. `GET
|
||
/api/v1/activities/{id}/quotes` lists the known quotes via
|
||
`list_activity_quotes`: visibility-filtered local `quote` activities —
|
||
locally authored ones and remote quotes materialized into `Activity`
|
||
rows — serialized as full `ActivityResponse`s, plus remote quotes rebuilt
|
||
from Pubby `InteractionType.QUOTE` records keyed by the quoted object's
|
||
`source_id`, each carrying `quoted` (the quoted object id) instead of the
|
||
reply's `in_reply_to`. Materialized remote quotes are filtered out of the
|
||
remote list so they render only as cards.
|
||
The frontend renders them Mastodon-style: every descendant of a direct
|
||
reply is unfolded flat into that reply's thread, each thread marked by its
|
||
own vertical line. All four listings gate on `can_view_activity` and allow
|
||
anonymous reads of public targets.
|
||
`GET /api/v1/activities/{id}` returns a single activity under the same
|
||
rules — it backs the notification UI's embedded activity cards and the
|
||
SPA's `/activities/:id` permalink page (`views/ActivityView.vue`), which
|
||
also lists the known quotes (`GET /{id}/quotes`) under the card — local
|
||
ones as `ActivityCard`s, federated-only ones as `ActivityRemoteReply`
|
||
rows — failure-isolated so a listing error cannot sink the page.
|
||
`ActivityCard.vue` carries a separate Quote action next to Reply: a
|
||
`StatusComposer` posts the quote through `POST
|
||
/api/v1/activities/{id}/quote`, the `quote_count` button toggles a flat
|
||
chronological list of local and remote quotes, and a quote card itself
|
||
embeds the quoted post via `ActivityObjectEmbed`.
|
||
`GET /api/v1/activities/lookup?url=...` resolves an ActivityPub object URL
|
||
to the same `ActivityResponse`: it matches `Activity.source_id` directly
|
||
(covering materialized remote replies whose object ids live on remote
|
||
paths) and, for `{actor}/objects/{uuid}` shapes, `local_object_id`. Browser
|
||
requests to a local `/users/{name}/objects/{id}` that map to a stored
|
||
activity are redirected by `federation.py` to the `/activities/{id}`
|
||
permalink, while ActivityPub `Accept` headers still get the object JSON.
|
||
Front proxies that serve the SPA shell for browser traffic bypass that
|
||
redirect, so the SPA also mounts a matching
|
||
`/users/{username}/objects/{objectId}` route
|
||
(`views/ObjectPermalinkView.vue`) that resolves the permalink client-side
|
||
through `/api/v1/remote/lookup` (which maps local URLs to their SPA route
|
||
without any remote fetch, and is therefore exempt from the
|
||
`remote_search_access` policy for local targets) with an
|
||
`/api/v1/activities/lookup` fallback.
|
||
Notification payloads denormalize `object_activity_id`/`object_type`/
|
||
`object_page_url` for the note itself and `target_object_*` for the
|
||
replied-to/quoted activity, so clients can render real activity cards and
|
||
link replies to their parent instead of a remote object id that does not
|
||
dereference to a page.
|
||
|
||
`resolve_audience` maps an activity's visibility to the set of remote
|
||
**inbox URLs** it should reach: `public` and `followers` activities go to
|
||
the author's follower inboxes (`services/federation.get_follower_inboxes`
|
||
reads Pubby's `federation_followers` storage via `pubby.collect_inboxes`,
|
||
preferring `shared_inbox` and deduplicating) plus every remote mentioned
|
||
actor, while `mentioned` activities reach only the mentioned actors.
|
||
`public` activities additionally reach followers of objects in the
|
||
reply chain — remote actors can `Follow` a local object rather than an
|
||
actor (Friendica sends `Follow` on a thread's root item for conversation
|
||
subscriptions; see *Object follows* below), and
|
||
`services/federation.get_object_follower_inboxes` collects the inboxes
|
||
subscribed to the activity's own object id or any of its
|
||
`in_reply_to_activity_id` ancestors.
|
||
Mentions that resolved to a local user (`user_id` set) or an actor URL on
|
||
the local instance domain have no remote inbox and are excluded, as are
|
||
non-HTTP(S) actor URLs; `private` and `local` never federate. Mentioned
|
||
inboxes are resolved concurrently through `resolve_actor_inbox`, signed
|
||
with the owner's key.
|
||
|
||
`fan_out_activity` is the delivery step: it unions the resolved audience
|
||
with caller-supplied `extra_inboxes`, drops inboxes already booked, and
|
||
creates an `ActivityTarget` row per remaining inbox before enqueueing
|
||
`tasks.federation.deliver_activity` (which applies the federation gate, the
|
||
blocked-domain gate, and exponential-backoff retries). Targets are marked
|
||
`sent` once the task accepts the job, `failed` with `last_error` when
|
||
enqueueing raises, and `skipped` — without an attempt — for blocked or
|
||
non-allowed inbox domains; `pending` remains the default for rows staged
|
||
without a dispatch attempt. Fan-out no-ops for remote or retracted
|
||
activities, missing payloads, non-federating visibilities, disabled
|
||
federation, and owners without a signing key.
|
||
|
||
`VisibilityRules` centralizes the containment policy: `can_contain` and
|
||
`enforce_activity_visibility` (used by `create_local_activity`) validate an
|
||
activity's visibility against its entity's, while
|
||
`cascade_visibility_update` changes a local activity's visibility and fans
|
||
the change out to the inboxes recorded as `sent` in `activity_targets`.
|
||
When the new visibility still federates (`Visibility.federates`:
|
||
`mentioned`, `followers`, `public`) those inboxes receive an `Update`
|
||
carrying the new `to`/`cc` audience built by
|
||
`federation.activities.create_visibility_update_activity` on top of
|
||
`activity_audience`; when it does not (`private`, `local`) a
|
||
`Delete(Tombstone)` retracts the object instead. Remote activities and
|
||
soft-deleted local activities never fan out, and deliveries are signed with
|
||
the activity owner's key through `deletion.enqueue_activity_delivery`.
|
||
|
||
`PATCH /api/v1/activities/{id}` handles author edits behind an
|
||
`acl.can_manage` check on the containing entity (owner or admin — the same
|
||
gate `create_local_activity` applies to content-producing types). Only
|
||
fields present in the request body change: a `content` edit goes through
|
||
`services.activities.update_activity`, which re-runs the
|
||
`process_mentions` pipeline on the new `content_source` (honoring the
|
||
stored or newly supplied `content_type`): `content` is re-rendered as
|
||
mention-aware safe HTML, the `activity_mentions` rows are replaced with
|
||
the newly resolved set, and — when the stored `payload` embeds a dict
|
||
`object` — the object's `content` and `tag` are rebuilt
|
||
(`pubby.set_object_content` merges tags while preserving pre-existing
|
||
tags, then the pipeline's `Mention` tags and HTML are layered on) and it
|
||
is stamped with `updated`. `language` replaces the activity's BCP-47 tag
|
||
(`null` clears it) and rewrites or drops the object's `contentMap`.
|
||
`media_ids`/`track_ids` rebuild the object's `attachment` list: docs
|
||
stamped with `songhive:fileId`/`songhive:trackId` (emitted by
|
||
`stored_file_to_attachment`/`track_to_attachment`) are user-managed and
|
||
replaced wholesale, while unmarked entity-owned docs — e.g. a shared
|
||
track's own `Audio` attachment — are preserved; the same ownership,
|
||
access, visibility-escalation and share-grant rules as status creation
|
||
apply, evaluated against the post-edit visibility. A `visibility` edit
|
||
goes through `cascade_visibility_update` so already-delivered inboxes
|
||
receive an `Update` or a `Delete(Tombstone)`. Content edits then federate
|
||
through `services.activities.fan_out_activity_update` — invoked after both
|
||
edits so the final visibility applies — which wraps the rebuilt object in
|
||
an `Update` (`federation.activities.create_object_update_activity`, with
|
||
`to`/`cc` rewritten to the current audience) and delivers it via
|
||
`deletion.enqueue_activity_delivery` to every inbox recorded as `sent`.
|
||
`fan_out_activity` then re-delivers the stored payload to inboxes first
|
||
reached by the edit (e.g. a newly mentioned actor), so those recipients
|
||
get the `Create` carrying the updated object rather than an `Update` for
|
||
an object they have never seen. The fan-out no-ops for remote or
|
||
retracted activities, payloads without an embedded dict `object`,
|
||
non-federating visibilities, disabled federation, and owners without a
|
||
signing key.
|
||
|
||
`GET /api/v1/{entity_type}/{entity_id}/activities` (a second router in
|
||
`api/routes/activities.py`, mounted at `api_prefix`) is the public read
|
||
endpoint. It validates `entity_type` against `ACTIVITY_ENTITY_TYPES`
|
||
(400), resolves the entity (404), and gates on `acl.can_access` (403) —
|
||
anonymous requesters may read publicly accessible entities, matching the
|
||
other read endpoints; share tokens are not honored, consistent with the
|
||
like/edit routes. `services.activities.list_activities` then applies the
|
||
same per-activity visibility rules as `can_view_activity` in SQL
|
||
(`_activity_visibility_filter`) so pagination cannot leak or under-fill
|
||
pages, supports `activity_type`/`source_type` filters, and
|
||
keyset-paginates on `(published_at, id)` newest-first with an opaque
|
||
base64url cursor (`limit` 1–100, default 20; malformed cursors return
|
||
400). Responses are serialized through
|
||
`ActivityResponse`/`ActivityListResponse`: the raw `payload`,
|
||
`retracted`, and `deleted_at` stay internal while resolved mentions are
|
||
included.
|
||
|
||
Deletion is handled by `services/deletion.py`'s `cascade_delete_entity`,
|
||
which is invoked from every entity delete path (track, album, artist,
|
||
playlist, library). Local activities are soft-deleted (`deleted_at` set)
|
||
and a `Delete(Tombstone)` is enqueued through `deliver_activity` for every
|
||
inbox recorded as `sent` in `activity_targets`, signed with the activity
|
||
owner's private key; remote activities are hard-deleted without fan-out.
|
||
Single-activity retraction follows the same rules through
|
||
`services/activities.retract_activity`, exposed as `DELETE
|
||
/api/v1/activities/{id}` behind `acl.can_manage` on the containing entity —
|
||
except that retracting a `like`/`announce` enqueues an `Undo` wrapping the
|
||
originally federated reaction payload (matching the `unreact_activity`
|
||
path) instead of a `Delete(Tombstone)`, since `Delete` is not the
|
||
ActivityPub way to retract a reaction, and remote activities are
|
||
soft-deleted rather than removed: the remote instance owns retraction, and
|
||
the surviving row keeps the stored Pubby interaction deduplicated so the
|
||
reply cannot resurface.
|
||
|
||
Track fediverse publications are recorded as activities too: every publish
|
||
path — the manual `POST /api/v1/tracks/{id}/publish`, uploads and imports
|
||
of public tracks that opt in with `publish=true` (including the
|
||
`process_upload` Celery task and bulk library uploads), `PATCH
|
||
/api/v1/tracks/{id}` transitions to `public` that opt in with `publish=true`
|
||
(surfaced as a "Publish on the Fediverse" checkbox on the track metadata
|
||
edit forms; without it the transition only makes the track's URL publicly
|
||
accessible), and album visibility propagation to `public` — calls
|
||
`services/activities.record_track_publication`. Uploads are local-only by
|
||
default: without the flag the track gets no `federation_object_id` and no
|
||
publication activity. `record_track_publication` builds a `Create`
|
||
envelope around one of two object shapes, selected by `object_type`:
|
||
`audio` (the canonical publication — the `Audio` object built on the
|
||
track's freshly minted `federation_object_id`) or `note` (a share whose
|
||
`content` renders as the post body on every remote server and whose
|
||
`Audio`-typed attachment links back to the track's published object; a
|
||
fresh object id is minted per share). It stores the payload on a `create`
|
||
activity whose `source_id` matches the published object URL
|
||
(`{actor_url}/objects/{object_id}`), keeps the
|
||
one-off `status` post text in `content_source`, persists its resolved
|
||
mentions as `activity_mentions` rows, records the caller-selected
|
||
`visibility` (default `public`), and delivers through
|
||
`fan_out_activity` so each reached inbox is booked in `activity_targets`
|
||
and later retraction reaches exactly those inboxes. When a public track
|
||
goes private, `retract_track_publications` soft-deletes all the live
|
||
publication rows — both the canonical `Audio` and any `Note` shares —
|
||
delivering a `Delete(Tombstone)` per publication `source_id` through
|
||
`retract_activity`, alongside the track-level
|
||
`unpublish_track_activity` `Delete(Tombstone)`. Metadata edits on a
|
||
published track — `PATCH /api/v1/tracks/{id}` touching `title`,
|
||
`artist_name`, `description`, or `genre` — re-sync the stored object
|
||
through `services/activities.sync_track_publications`: each live local
|
||
`create` activity's `payload.object` is rebuilt from
|
||
`track_to_audio_object` or `track_to_note_object` depending on the stored
|
||
object `type` (keeping the object id stable — and the `published` stamp
|
||
for `Note` shares — while stamping
|
||
`updated`), a one-off `status` kept in `content_source` is re-applied as
|
||
the post body so the track description does not clobber it, and the
|
||
result fans out via `fan_out_activity_update` so delivered inboxes get an
|
||
`Update` carrying the refreshed object.
|
||
`get_activity_unpublish_info` returns the `ActivityUnpublishInfo`
|
||
(activity id, `source_id`, `actor_url`, sent inboxes) used for that
|
||
delivery, and `federation/activities.py` builds the `Delete(Tombstone)`
|
||
payload via `create_tombstone_delete_activity` — a thin adapter around
|
||
`pubby.build_delete_activity` (0.3.2) that keeps a plain string `@context`.
|
||
|
||
Standalone statuses — posts not attached to any media entity — are created
|
||
through `POST /api/v1/statuses/`, which calls
|
||
`services/activities.create_status` after provisioning the author's actor
|
||
(`ensure_user_actor`). The service records a `create` activity on the
|
||
author's `user` entity, so statuses show up in the author's profile posts
|
||
feed (`GET /api/v1/users/{username}/activities?mode=posts`) and can be
|
||
edited/retracted through the regular activity endpoints. Posts mode
|
||
returns the user's local `create` and `quote` activities plus —
|
||
Mastodon-style — their `announce` boosts unless `include_boosts=false`,
|
||
and their `reply` activities only with `include_replies=true` (replies
|
||
are hidden by default); `mode=all` returns every authored activity and
|
||
ignores the include flags. The request
|
||
carries the raw `status` source text (rendered through `process_mentions`
|
||
as `text/markdown` — the default — or `text/plain`), a `visibility`, an
|
||
optional BCP-47 `language` (validated, stored on the activity, mirrored
|
||
into the Note's `contentMap`), and two attachment sets: `media_ids`
|
||
reference previously uploaded `StoredFile`s (serialized by
|
||
`federation/serializers.stored_file_to_attachment`; the service escalates
|
||
each file's visibility to what the status audience requires — never
|
||
downgrades — and grants `file` shares to mentioned local users on
|
||
`mentioned`-visibility posts) and `track_ids` reference hosted tracks the
|
||
author may access (serialized by `track_to_attachment` as `Audio`
|
||
attachments embedding the stream URL, or `Document` links for tracks
|
||
without audio). Audio attachments — on statuses and on the `Audio`/`Note`
|
||
track objects alike — additionally carry the namespaced
|
||
`songhive:trackId`, `songhive:trackTitle`, `songhive:artistName`,
|
||
`songhive:albumName` and `songhive:trackUrl` keys plus a standard `image`
|
||
entry resolving cover art (track image, then album cover file, then the
|
||
album's remote `cover_url`, then the artist's image file or remote
|
||
`image_url`), so music-aware consumers can render a rich
|
||
player without parsing the flat `name` label; remote servers ignore
|
||
unknown keys. Each category is capped at four attachments, and a status
|
||
may be attachments-only. The resulting `Create(Note)` is built by
|
||
`federation/activities.create_status_activity` and fanned out through
|
||
`fan_out_activity` exactly like entity activities: it federates to the
|
||
visibility audience when federation is enabled, and stays local otherwise.
|
||
The manual track publish endpoint accepts the same `content_type`,
|
||
`language`, and `media_ids` fields, so the composer can also drive
|
||
`POST /api/v1/tracks/{id}/publish`. Statuses, replies, quotes, activity
|
||
edits and track publications also accept an `audio_import` object
|
||
(`upload_to_library`, default `true`; `fetch_metadata`, default `false`;
|
||
`library_id`, default the author's lazily-created private "Uploads"
|
||
library): when enabled, each `audio/*` file attachment is imported into
|
||
the target library as a track after the post is saved, via
|
||
`plan_audio_import`/`apply_audio_import` (`api/routes/files.py`) and
|
||
`services/import_.import_stored_audio_files`, which reuses the file's
|
||
existing `Track` when it already backs one of the author's tracks and
|
||
treats external duplicates as `keep_local`. The import plan is validated
|
||
before the activity fans out so a bad `library_id` fails the request
|
||
without enqueued deliveries; per-file import failures are logged and
|
||
skipped so the post always succeeds. To keep the choice effective at
|
||
post time, the composer uploads browser files with
|
||
`import_audio=false`, which stores `audio/*` uploads as plain
|
||
`StoredFile`s (still audio-hashed, so the post-time import dedupes)
|
||
instead of auto-importing them. Users pick their default post format
|
||
through the `status_content_type` profile field (`PATCH
|
||
/api/v1/users/me`), defaulting to `text/markdown`.
|
||
|
||
`GET /users/{username}/objects/{object_id}` in `api/routes/federation.py`
|
||
is the dereference endpoint for federated objects. Public tracks still
|
||
resolve through `Track.federation_object_id` to their `Audio` object; when
|
||
no track matches, an `Activity` is resolved by `local_object_id` or
|
||
`source_id`, scoped to the requested user (`owner_user_id`), so an object
|
||
is only served under its owner's namespace. Soft-deleted activities answer
|
||
with a `Tombstone` object (`federation/activities.build_tombstone_object`)
|
||
whose shape mirrors the object embedded in `Delete(Tombstone)` deliveries.
|
||
Live activities are only served when their visibility federates
|
||
(`Visibility.federates`: `mentioned`, `followers`, `public`) —
|
||
`private`/`local` objects were never distributed and answer 404.
|
||
`federation/activities.build_activity_object` produces the document: the
|
||
stored `payload` verbatim for payload-bearing activities (e.g. `Like`) —
|
||
except `Create` envelopes, whose embedded object is served instead because
|
||
the activity's `source_id` identifies the published object, not the
|
||
envelope — or a synthesized `Note` carrying the rendered `content`, the
|
||
`activity_audience`-derived `to`/`cc`, `Mention` tags, and `inReplyTo`.
|
||
Every post object — `track_to_audio_object`/`track_to_note_object`
|
||
serials, status and reply `Note`s, and payload-derived documents at serve
|
||
time — is stamped by `pubby.allow_public_quotes` with
|
||
`interactionPolicy.canQuote` granting `automaticApproval` to `as:Public`
|
||
(FEP-044f): quoting is always allowed, so Mastodon enables its Quote
|
||
action and auto-approves the quote once the `QuoteAuthorization` it
|
||
fetches matches. Restamping at serve time keeps objects published before
|
||
the policy existed quotable without rewriting stored payloads.
|
||
Because object URLs double as the objects' own `url` (a `Note` share's
|
||
permalink on remote servers), clients not accepting an ActivityStreams
|
||
media type are redirected to the SPA rather than served JSON: a
|
||
track-resolved object redirects to `/tracks/{id}`, an activity-resolved
|
||
object to `/{entity_type}s/{entity_id}/activities` (`libraries` for
|
||
`library`).
|
||
|
||
Served track objects are built by `_track_object_document`, which extends
|
||
the `track_to_audio_object` payload with a top-level `@context` and the
|
||
`public` `to`/`cc` audience — remote fetchers (e.g. Mastodon's URL lookup)
|
||
reject context-less documents, and an audience-less object would be
|
||
imported as a direct-only status. The object's `attributedTo` leads with
|
||
the publishing actor rather than the artist page URL because remote
|
||
importers take its first entry as the author and only actor URLs are
|
||
dereferenceable. `GET /tracks/{track_id}` content-negotiates on top of
|
||
that: requests accepting `application/activity+json`/`application/ld+json`
|
||
receive the same `Audio` document (the track page URL is the `text/html`
|
||
`url` advertised in every published object, so remote servers fetch it when
|
||
a user pastes the link into a search box), while browsers receive the SPA
|
||
shell annotated with a `Link: rel="alternate"` header and a
|
||
`<link rel="alternate" type="application/activity+json">` element pointing
|
||
at the object URL. The object is only served while the track is published
|
||
(`federation_object_id` set). When no `Audio` exists, ActivityPub fetches
|
||
are redirected (303 See Other) to the track's earliest live local `create`
|
||
activity — `_earliest_track_post` picks the oldest non-deleted,
|
||
federating-visibility row and the object route then serves that share's
|
||
`Note` document — so a URL search on an Audio-less track still resolves to
|
||
a post (remote fetchers like Mastodon follow the redirect and import the
|
||
redirected object under its own `id`). Tracks with neither a published
|
||
object nor live shares answer 404 so a remote fetch cannot resurrect a
|
||
retracted post under a different id.
|
||
|
||
`GET /activities/{activity_id}` applies the same content negotiation to
|
||
the SPA's activity permalink — the URL `ActivityCard` offers for copying —
|
||
so pasting it into a remote search box resolves too. ActivityPub fetches
|
||
of a local activity receive the same document the canonical object route
|
||
serves (`_activity_object_response`, shared with `get_object`), gated on
|
||
the owner being an active local user; remote-sourced activities redirect
|
||
(303 See Other) to their origin's object id, which stays authoritative.
|
||
Browsers receive the SPA shell annotated with the `Link`/
|
||
`<link rel="alternate">` discovery hints pointing at `source_id`, the
|
||
canonical object URL.
|
||
|
||
### Genres
|
||
|
||
Genres are stored in a dedicated `Genre` table and linked to `Track` and
|
||
`Album` through `GenreTrack` and `GenreAlbum` association tables. The free-text
|
||
`Track.genre` and `Album.genre` columns remain the source of truth for embedded
|
||
metadata round-trips, while the normalised tables enable browsing, counting, and
|
||
filtering. `GenreTrack.inherited` distinguishes album-inherited values from
|
||
explicit track-level overrides. `services/genres.py` validates names, manages
|
||
associations, propagates album genres down to tracks that have no explicit
|
||
genre of their own, and re-derives the album genre from the intersection of its
|
||
tracks' explicit genres. The tag system receives the same genre-derived
|
||
tags: the genre string is split and mapped to valid tag names so
|
||
genre-derived tags appear alongside user-created ones. The public API
|
||
exposes global genre listing and deletion in `api/routes/genres.py`, and
|
||
per-resource genre management is supported through `POST`/`DELETE` sub-routes on
|
||
tracks and albums as well as the `genre` field on track/album updates. The
|
||
frontend mirrors the tag browsing experience: `GenresView` and `GenreView`
|
||
list and filter genres, `GenreInput`/`GenreList` let users edit and display
|
||
genres on tracks and albums, and the sidebar provides a top-level "Genres"
|
||
navigation link.
|
||
|
||
---
|
||
|
||
## External Libraries
|
||
|
||
Songhive supports attaching external music libraries (e.g., cloud storage
|
||
adapters) to user- or admin-owned Songhive libraries. The feature is built
|
||
around a pluggable adapter model in `songhive/external/`, with a per-provider
|
||
registry (`songhive/external/registry.py`) and a `BaseExternalLibraryAdapter`
|
||
interface.
|
||
|
||
Adapters live in child plans and are registered at import time. Each adapter
|
||
exposes capabilities (`list_items`, `read_metadata`, `open_stream`, `download`,
|
||
`write_tags`, `rename_source`, `delete_source`, `compute_hash`) and a sanitized
|
||
configuration schema. Provider-specific implementations are kept outside the
|
||
core codebase and register through `songhive.external.registry`.
|
||
|
||
Models:
|
||
|
||
- `ExternalLibrary` — adapter instance, encrypted provider config, capabilities,
|
||
and scope (`user` or `admin`).
|
||
- `ExternalTrack` — a track discovered through a file-oriented external
|
||
provider, tied to an `ExternalLibrary` and optionally to a Songhive `Track`.
|
||
- `ExternalItem` — an entity discovered through an entity-backed provider
|
||
(`kind` in `track`/`album`/`artist`/`playlist`), tying a Songhive entity to
|
||
its provider-side reference (etag, mtime, raw provider metadata).
|
||
- `ExternalSyncRun` — a record of each sync attempt, including status,
|
||
triggered-by, and error details.
|
||
|
||
Two provider families exist behind the same adapter interface. *File
|
||
providers* (`local`, `s3`, `gdrive`, `sftp`, `webdav`, `dropbox`, `http`)
|
||
enumerate remote audio files; each produces an `ExternalTrack` row that keeps
|
||
file-oriented state (sha256, mime, size, write-back/rename/delete state), and
|
||
the owning `Track` gets `audio_file_id = NULL`. *Entity-backed providers*
|
||
(identified by `capabilities.limits["entity_import"]`, currently `jellyfin`,
|
||
`tidal`, and `youtube`) enumerate tracks, albums, artists, and playlists as provider items;
|
||
each imported Songhive entity gets an `ExternalItem` row in the matching `kind`
|
||
instead. The generic `Track.source` stays `"external"` for both — provenance is
|
||
recovered through `ExternalItem.external_library.provider_type` — and audio
|
||
bytes stay remote: at playback/download time the provider resolves the item
|
||
into a stream that Songhive proxies, so provider credentials never reach the
|
||
client (`safe_to_redirect = False`).
|
||
|
||
API surfaces:
|
||
|
||
- `api/routes/external_libraries.py` — user-facing CRUD, sync, sync-run listing,
|
||
and track management under `/api/v1/external-libraries/...`.
|
||
- `api/routes/admin_external_libraries.py` — admin CRUD, sync, and track
|
||
management under `/api/v1/admin/external-libraries/...`.
|
||
|
||
User-created external libraries are governed by
|
||
`config.external_libraries.allow_user_created_libraries` and the
|
||
allow/deny provider lists. Admin libraries can be included in the public library
|
||
index when `allow_admin_library_index_inclusion` is enabled.
|
||
|
||
The built-in `local` provider (`external/_local.py`) lets admins mount
|
||
server-side directories as external libraries. Root paths are restricted to an
|
||
allowlist configured in `external_libraries.local_roots` for safety. Files under
|
||
the root are enumerated recursively, metadata is read from embedded tags, and
|
||
the directory is treated as a local audio source for streaming and download.
|
||
A separate filesystem watchdog process (`external/watchdog.py`) monitors enabled
|
||
local libraries and enqueues incremental or scoped syncs through
|
||
`sync_external_library_task` as files change, with a quiet window to coalesce
|
||
bursts of events. The watchdog is started via `songhive watch-external-libraries`
|
||
and is also available as a `watcher` container in `docker-compose.yml`. If no
|
||
local libraries are configured, the process polls the database every few seconds
|
||
and starts watching automatically once libraries are created, so it does not need
|
||
to be restarted after the stack is already running.
|
||
|
||
The watcher is intentionally a standalone process rather than a thread inside the
|
||
main web server: filesystem watches can be long-lived, are best as a single
|
||
instance per host, and have a different failure/restart profile than HTTP
|
||
workers. In a non-Docker deployment you should run it as a separate process
|
||
(`songhive watch-external-libraries`) or under a supervisor such as systemd.
|
||
|
||
#### Local provider settings
|
||
|
||
A local external library stores the following adapter config in `ExternalLibrary.config`:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------|----------|--------------|-----------------------------------------------------------------------------|
|
||
| `root` | yes | — | Absolute or relative path to the directory to scan. |
|
||
| `extensions` | no | all audio | List of file extensions to index, e.g. `[".mp3", ".flac"]`. |
|
||
| `recursive` | no | `true` | Whether to scan subdirectories. |
|
||
| `follow_symlinks` | no | `false` | If `false`, symlinks are ignored; if `true`, resolved targets must stay within `root`. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to root-relative paths. |
|
||
| `allow_hashing` | no | `true` | Whether to compute a SHA-256 hash for new/updated files. |
|
||
| `fast_hash` | no | `false` | If `true`, hash the raw file bytes; otherwise use ffmpeg audio-only hashing. |
|
||
| `allow_write_tags` | no | `false` | Whether Songhive metadata edits may be written back to the source files. |
|
||
| `allow_rename_source`| no | `false` | Whether the `rename_source` operation may rename the backing file. |
|
||
| `allow_delete_source`| no | `false` | Whether the `delete_source` operation may remove the backing file. |
|
||
|
||
The global `external_libraries.local_roots` allowlist is required: a library's
|
||
`root` must resolve to a path inside one of the configured roots, or validation
|
||
fails.
|
||
|
||
#### S3 provider
|
||
|
||
The `s3` provider (`external/_s3.py`) indexes audio objects stored in an
|
||
S3-compatible bucket (AWS S3, MinIO, and other S3 API-compatible services).
|
||
Both regular users (when `allow_user_created_libraries` permits the provider)
|
||
and admins can attach buckets; like every external library, the backing
|
||
`Library` is private to its owner by default and can be made `public`/`local`
|
||
or shared explicitly through library share grants.
|
||
|
||
An S3 external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|-----------------------------|----------|-------------|--------------------------------------------------------------------------|
|
||
| `bucket` | yes | — | Bucket containing the audio objects. |
|
||
| `prefix` | no | `""` | Only index objects under this key prefix. |
|
||
| `endpoint_url` | no | AWS | Custom endpoint for S3-compatible services (e.g. `http://minio:9000`). |
|
||
| `region` | no | — | Bucket region. |
|
||
| `access_key`/`secret_key` | no | ambient | Static credentials; when omitted the SDK's ambient credentials are used. |
|
||
| `path_style` | no | `false` | Force path-style addressing (needed by some S3-compatible services). |
|
||
| `presigned_urls` | no | `true` | Redirect clients to short-lived presigned GET URLs for playback. |
|
||
| `presigned_expiry_seconds` | no | `3600` | Presigned URL lifetime, clamped to 60–604800 (SigV4's one-week limit). |
|
||
| `extensions` | no | all audio | List of object-key suffixes to index. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to prefix-relative keys. |
|
||
| `recursive` | no | `true` | When `false`, only index keys directly under `prefix` (delimiter `/`). |
|
||
| `allow_hashing` | no | `true` | Whether to compute audio hashes for new/updated objects. |
|
||
| `fast_hash` | no | `false` | Hash raw object bytes instead of ffmpeg audio-only hashing. |
|
||
| `allow_write_tags` | no | `false` | Rewrite embedded tags by re-uploading the object in place. |
|
||
| `allow_rename_source` | no | `false` | Allow `rename_source` (server-side copy + delete). |
|
||
| `allow_delete_source` | no | `false` | Allow `delete_source` to remove objects. |
|
||
|
||
Streaming defaults to presigned GET URLs returned with `safe_to_redirect`
|
||
(the client fetches bytes directly from the object store, including HTTP
|
||
ranges). When `presigned_urls` is `false`, Songhive proxies the object body
|
||
through the normal stream handler, so bandwidth flows through the server.
|
||
Hashing prefers the object's S3 `ChecksumSHA256` attribute when present,
|
||
then falls back to `fast_hash` streaming or a temp-file ffmpeg pass —
|
||
all gated on `allow_hashing` because they require downloading the object.
|
||
|
||
#### S3 freshness vs. filesystem watching
|
||
|
||
The filesystem watchdog only applies to the `local` provider — object stores
|
||
have no `watchfiles`-style change events, so S3 libraries rely on scheduled
|
||
syncs (`sync_interval_seconds`, driven by `scan_scheduled_syncs_task`).
|
||
To keep polling cheap, `iter_items` lists the bucket prefix with
|
||
`ListObjectsV2` (paginated, no object payloads) and reports each object's
|
||
`ETag`, `LastModified`, and size. `external/sync.py` advertises this through
|
||
the `detect_changes` capability: when the stored `provider_etag` (or
|
||
mtime+size) still matches, the item is treated as unchanged and sync skips
|
||
metadata reads, hashing, and downloads entirely. A scheduled sync over an
|
||
unchanged bucket therefore costs one `ListObjectsV2` call set — no per-object
|
||
transfer — while still catching new, modified, and deleted keys on the next
|
||
interval. Operators wanting tighter freshness should shorten
|
||
`sync_interval_seconds` rather than run manual full scans; scoped syncs
|
||
(`scope=`) can restrict a sync to a sub-prefix. Future integrations could
|
||
plug S3 event notifications or S3 Inventory into the same task pipeline.
|
||
|
||
#### SFTP provider
|
||
|
||
The `sftp` provider (`external/_sftp.py`, asyncssh) indexes audio files on a
|
||
remote host reached over SSH/SFTP. **The remote host must be reachable from
|
||
the Songhive instance** — every listing, download, and mutation originates
|
||
server-side, so firewalls/NAT between Songhive and the SSH server must allow
|
||
outbound connections to the configured host and port. Like S3, both regular
|
||
users (when `allow_user_created_libraries` permits the provider) and admins
|
||
can attach SFTP roots.
|
||
|
||
An SFTP external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------------|----------|----------|------------------------------------------------------------------------|
|
||
| `host` | yes | — | Hostname or IP of the SSH server. |
|
||
| `port` | no | `22` | SSH port. |
|
||
| `username` | yes | — | SSH login user. |
|
||
| `password` | no | — | SSH password (stored encrypted). |
|
||
| `private_key` | no | — | PEM/OpenSSH private key (stored encrypted). |
|
||
| `private_key_passphrase` | no | — | Passphrase for an encrypted `private_key` (stored encrypted). |
|
||
| `verify_host_key` | no | `true` | Verify the server host key against `known_hosts` or the default files. |
|
||
| `known_hosts` | no | — | Inline OpenSSH `known_hosts` lines used when `verify_host_key` is on. |
|
||
| `root` | no | `.` | Remote directory to index; relative paths resolve against the login home. |
|
||
| `connect_timeout` | no | `15` | Seconds to wait for TCP connect and SSH login. |
|
||
| `extensions` | no | all audio| List of file extensions to index. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to root-relative paths. |
|
||
| `recursive` | no | `true` | Whether to scan subdirectories. |
|
||
| `follow_symlinks` | no | `false` | Whether to follow symbolic links while scanning. |
|
||
| `allow_hashing` | no | `true` | Whether to compute audio hashes for new/updated files. |
|
||
| `fast_hash` | no | `false` | Hash raw file bytes instead of ffmpeg audio-only hashing. |
|
||
| `allow_write_tags` | no | `false` | Rewrite embedded tags by re-uploading the file in place. |
|
||
| `allow_rename_source` | no | `false` | Allow `rename_source` to rename remote files. |
|
||
| `allow_delete_source` | no | `false` | Allow `delete_source` to remove remote files. |
|
||
|
||
Authentication accepts a password, an inline private key (optionally
|
||
passphrase-protected), or both — asyncssh presents whichever the server
|
||
accepts. With neither configured, the Songhive process's default SSH client
|
||
keys and agent are tried, mirroring the S3 adapter's ambient-credentials
|
||
behaviour.
|
||
`verify_host_key` defaults on and checks the host key against the inline
|
||
`known_hosts` data or the process's `~/.ssh/known_hosts`; disabling it must be
|
||
an explicit `verify_host_key = false`, since an unverified connection is open
|
||
to MITM impersonation (including credential theft when password auth is used).
|
||
|
||
SFTP has no presignable URL, so `open_stream` always returns a proxied byte
|
||
iterator (with byte-range support via positioned reads). Freshness follows
|
||
the S3 scheduled-sync model: `iter_items` walks the remote tree with
|
||
`READDIR` (metadata only) and reports an `mtime:size` change token via
|
||
`detect_changes`, so unchanged trees cost a directory walk and no file
|
||
transfer.
|
||
|
||
#### WebDAV provider
|
||
|
||
The `webdav` provider (`external/_webdav.py`, httpx) indexes audio files
|
||
stored on a remote WebDAV server (Nextcloud, ownCloud, nginx WebDAV, etc.).
|
||
**The server must be reachable from the Songhive instance** — every listing,
|
||
download, and mutation originates server-side, so firewalls/NAT between
|
||
Songhive and the WebDAV host must allow outbound HTTP or HTTPS connections.
|
||
Like S3 and SFTP, both regular users (when `allow_user_created_libraries`
|
||
permits the provider) and admins can attach WebDAV roots.
|
||
|
||
A WebDAV external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------------|----------|----------|---------------------------------------------------------------------------|
|
||
| `url` | yes | — | Base WebDAV URL, e.g. `https://nextcloud.example.com/remote.php/dav`. |
|
||
| `root` | no | `""` | Remote directory under the base URL to index. |
|
||
| `username` | no | — | HTTP Basic auth username. |
|
||
| `password` | no | — | HTTP Basic auth password (stored encrypted). |
|
||
| `token` | no | — | Bearer token sent in the `Authorization` header. |
|
||
| `verify_ssl` | no | `true` | Verify TLS; set to `false` to disable or to a CA bundle path. |
|
||
| `timeout` | no | `30` | HTTP request timeout in seconds. |
|
||
| `extensions` | no | all audio| List of file extensions to index. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to root-relative paths. |
|
||
| `recursive` | no | `true` | Whether to scan subdirectories. |
|
||
| `allow_hashing` | no | `true` | Whether to compute audio hashes for new/updated files. |
|
||
| `fast_hash` | no | `false` | Hash raw file bytes instead of ffmpeg audio-only hashing. |
|
||
| `allow_write_tags` | no | `false` | Rewrite embedded tags by re-uploading the file in place. |
|
||
| `allow_rename_source` | no | `false` | Allow `rename_source` to rename remote files. |
|
||
| `allow_delete_source` | no | `false` | Allow `delete_source` to remove remote files. |
|
||
|
||
Authentication accepts a `username`/`password` pair (HTTP Basic auth) or a
|
||
`token` (Bearer auth). `verify_ssl` defaults on and should only be disabled
|
||
for testing or trusted private networks, since disabling it leaves the
|
||
connection open to MITM impersonation and credential theft.
|
||
|
||
WebDAV has no presignable URL, so `open_stream` always returns a proxied byte
|
||
iterator with HTTP `Range` support. `iter_items` walks the remote tree with
|
||
WebDAV `PROPFIND` (metadata only) and reports an ETag/mtime/size change token
|
||
via `detect_changes`, so unchanged trees cost a directory walk and no file
|
||
transfer. As with S3 and SFTP, freshness comes from scheduled syncs rather
|
||
than filesystem watching.
|
||
|
||
#### Dropbox provider
|
||
|
||
The `dropbox` provider (`external/_dropbox.py`, httpx) indexes audio files
|
||
stored in a Dropbox account through the Dropbox HTTP API v2. All traffic
|
||
originates server-side, so the Songhive instance needs outbound HTTPS access
|
||
to `api.dropboxapi.com` and `content.dropboxapi.com` (plus
|
||
`dl.dropboxusercontent.com` when clients fetch temporary links directly).
|
||
Like the other providers, both regular users (when
|
||
`allow_user_created_libraries` permits the provider) and admins can attach
|
||
Dropbox folders.
|
||
|
||
A Dropbox external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------------|----------|----------|---------------------------------------------------------------------------|
|
||
| `access_token` | yes* | — | Dropbox OAuth access token (short-lived, ~4 hours). |
|
||
| `refresh_token` | yes* | — | OAuth refresh token — the durable option; mints access tokens on demand. |
|
||
| `app_key` | with `refresh_token` | — | App key of the Dropbox app the refresh token was issued to. |
|
||
| `app_secret` | no | — | App secret of the Dropbox app, if it has one (stored encrypted). |
|
||
| `account_id` | no | — | Dropbox account id, filled in by the OAuth connect flow. |
|
||
| `root` | no | `""` | Dropbox folder to index (e.g. `/Music`); empty indexes the account root. |
|
||
| `timeout` | no | `30` | HTTP request timeout in seconds. |
|
||
| `temporary_links` | no | `false` | Redirect clients to short-lived `get_temporary_link` URLs for playback. |
|
||
| `extensions` | no | all audio| List of file extensions to index. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to root-relative paths. |
|
||
| `recursive` | no | `true` | Whether to scan subfolders of the root. |
|
||
| `allow_hashing` | no | `true` | Whether to compute audio hashes for new/updated files. |
|
||
| `fast_hash` | no | `false` | Hash raw file bytes instead of ffmpeg audio-only hashing. |
|
||
| `allow_write_tags` | no | `false` | Rewrite embedded tags by re-uploading the file in place. |
|
||
| `allow_rename_source` | no | `false` | Allow `rename_source` to rename Dropbox files. |
|
||
| `allow_delete_source` | no | `false` | Allow `delete_source` to remove Dropbox files. |
|
||
|
||
*Exactly one credential path is required: a static `access_token`, or a
|
||
`refresh_token` + `app_key` (+ optional `app_secret`) grant. Since Dropbox
|
||
access tokens only live ~4 hours, the refresh grant is the durable option —
|
||
the adapter exchanges it via `oauth2/token`, caches the minted access token
|
||
process-wide for its declared lifetime, and retries once with a fresh token
|
||
when a cached one is rejected.
|
||
|
||
The Dropbox app itself must be a "scoped access" app with
|
||
`account_info.read`, `files.metadata.read` and `files.content.read`
|
||
permissions (plus `files.metadata.write` and `files.content.write` when tag
|
||
write-back, rename or delete is wanted). Scope grants are not retroactive:
|
||
enabling a permission on the app does not extend tokens that were already
|
||
issued — after changing app permissions the account must be re-connected so
|
||
a new token is granted with the updated scope set.
|
||
|
||
Streaming defaults to proxying the file body through `files/download`, which
|
||
honours `Range` headers — it works in every browser. Setting
|
||
`temporary_links` redirects clients to short-lived `get_temporary_link` URLs
|
||
instead (`safe_to_redirect`; the client fetches bytes directly from Dropbox,
|
||
including HTTP ranges; links are valid ~4 hours). That offloads bandwidth to
|
||
Dropbox's CDN but **fails in Firefox**: `dl.dropboxusercontent.com` serves
|
||
`Content-Security-Policy: sandbox`, which Firefox applies to media loads —
|
||
enable it only for Chromium/Safari audiences. Tag write-back re-downloads the
|
||
file, rewrites tags locally, and re-uploads it in place — payloads above
|
||
Dropbox's 150 MiB single-upload cap go through chunked upload sessions
|
||
automatically.
|
||
|
||
`iter_items` pages `files/list_folder` (metadata only) and reports each
|
||
file's `rev` as the change token via `detect_changes`, so unchanged trees
|
||
cost a listing and no file transfer. The provider `content_hash` (SHA-256 of
|
||
4 MiB block hashes) is kept on `ExternalItemRef.checksum` but is never
|
||
treated as the audio sha256. As with the other remote providers, freshness
|
||
comes from scheduled syncs rather than filesystem watching.
|
||
|
||
#### OAuth connect flow
|
||
|
||
Providers that authenticate through OAuth (`dropbox` and `gdrive` today;
|
||
Spotify, Tidal and YouTube are planned) offer a "Connect" button in the
|
||
external-library form instead of making users paste tokens by hand. The
|
||
machinery is provider-agnostic (`external/oauth.py`): each provider
|
||
registers an `OAuthProviderSpec` describing its authorize/token endpoints,
|
||
which config keys carry the OAuth client id/secret, and how token-response
|
||
fields map onto adapter config keys. A spec may also set
|
||
`scopes_for_config` to derive the requested OAuth scopes from the submitted
|
||
config — Google Drive uses it to request `drive.readonly` or the full
|
||
`drive` scope depending on the form's write flags. Provider listings report
|
||
`oauth_supported` and `oauth_callback_url` (the public
|
||
`{base}/api/v1/external-libraries/oauth/callback` URL built from
|
||
`public_base_url`) so the frontend only shows the button where it applies
|
||
and can tell the user which redirect URI to register in the provider's app
|
||
console.
|
||
|
||
The flow runs in three steps:
|
||
|
||
1. `POST /api/v1/external-libraries/oauth/begin` (authenticated) validates
|
||
the provider type and stores a pending entry in Redis — bound to the
|
||
initiating user, the client credentials, a PKCE verifier, the callback
|
||
`redirect_uri` and the page to return to — under a random `state` key
|
||
(10-minute TTL). The response carries the provider's authorization URL,
|
||
which the SPA navigates to after stashing the form in `sessionStorage`.
|
||
2. `GET /api/v1/external-libraries/oauth/callback` (unauthenticated; the
|
||
`state` parameter binds it to the pending flow) consumes the pending
|
||
entry once, exchanges the authorization code for tokens server-side and
|
||
stores the resulting config fragment under a result key (5-minute TTL)
|
||
before redirecting back to the SPA. Provider errors and exchange
|
||
failures redirect with `oauth_error` instead; `return_to` is restricted
|
||
to local paths.
|
||
3. `POST /api/v1/external-libraries/oauth/claim` (authenticated) lets the
|
||
SPA collect the fragment once — results are deleted on claim and a claim
|
||
by a different user leaves the entry in place.
|
||
|
||
The granted fragment is merged into the provider configuration and only
|
||
persisted when the user saves the form, at which point it goes through the
|
||
same validation and Fernet encryption as manually entered credentials.
|
||
Dropbox uses `token_access_type=offline` and Google Drive
|
||
`access_type=offline` with `prompt=consent` so the granted `refresh_token`
|
||
is durable; a future provider only needs to register a spec — no new
|
||
routes, Redis plumbing or frontend redirect handling.
|
||
|
||
#### Google Drive provider
|
||
|
||
The `gdrive` provider (`external/_gdrive.py`, httpx) indexes audio files
|
||
stored in a Google Drive folder, including shared (team) drives. **The
|
||
Songhive instance must be able to reach `googleapis.com`** — every listing,
|
||
download, and mutation originates server-side through the Drive API v3.
|
||
Like the other providers, both regular users (when
|
||
`allow_user_created_libraries` permits the provider) and admins can attach
|
||
Drive roots.
|
||
|
||
A Google Drive external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------------|----------|----------|-------------------------------------------------------------------------|
|
||
| `client_id` | OAuth | — | OAuth 2.0 client ID of a Google Cloud "Web application" client; the |
|
||
| | | | Connect flow fills `access_token`/`refresh_token` from it. |
|
||
| `client_secret` | OAuth | — | OAuth 2.0 client secret (stored encrypted). |
|
||
| `refresh_token` | OAuth | — | OAuth refresh token; granted by the Connect flow (stored encrypted). |
|
||
| `access_token` | yes* | — | Static bearer token; expires after ~1h — testing only. |
|
||
| `service_account_key` | yes* | — | Service account JSON (`client_email`/`private_key`); takes precedence. |
|
||
| `root_folder_id` | no | `root` | Folder ID to index; defaults to the My Drive root, or the shared drive |
|
||
| | | | root when `drive_id` is set. |
|
||
| `drive_id` | no | — | Shared drive ID; enables `supportsAllDrives` and scopes lists to it. |
|
||
| `token_uri` | no | Google | OAuth token endpoint override. |
|
||
| `verify_ssl` | no | `true` | Verify TLS; set to `false` to disable or to a CA bundle path. |
|
||
| `timeout` | no | `30` | HTTP request timeout in seconds. |
|
||
| `extensions` | no | all audio| List of file extensions to index. |
|
||
| `exclude` | no | `[]` | `fnmatch` patterns applied to display paths and file names. |
|
||
| `recursive` | no | `true` | Whether to scan subfolders. |
|
||
| `allow_hashing` | no | `true` | Whether to compute audio hashes for new/updated files. |
|
||
| `fast_hash` | no | `false` | Hash raw file bytes instead of ffmpeg audio-only hashing. |
|
||
| `trash_on_delete` | no | `true` | Move files to the Drive trash instead of permanently deleting. |
|
||
| `allow_write_tags` | no | `false` | Rewrite embedded tags by re-uploading the file in place. |
|
||
| `allow_rename_source` | no | `false` | Allow `rename_source` to rename remote files (file ID stays stable). |
|
||
| `allow_delete_source` | no | `false` | Allow `delete_source` to trash or remove remote files. |
|
||
|
||
*Exactly one credential mode is required: OAuth user credentials, a service
|
||
account key, or a static access token. The UI "Connect" button runs the
|
||
generic OAuth connect flow against `accounts.google.com` — the user
|
||
registers a "Web application" OAuth client in the Google Cloud console with
|
||
the reported `oauth_callback_url` as an authorized redirect URI, and the
|
||
granted `refresh_token` (requested with `access_type=offline` +
|
||
`prompt=consent`, so re-connects always return one) lands in the config
|
||
automatically. The authorize request asks for `drive.readonly`, or the full
|
||
`drive` scope when any write flag is enabled in the form. Service account
|
||
access tokens are minted locally with an RS256 JWT bearer grant (using the
|
||
`cryptography` package already required for secret-at-rest encryption) and
|
||
request the same scope rule. OAuth tokens are refreshed through the token
|
||
endpoint and cached for their stated lifetime.
|
||
|
||
Unlike path-addressed providers, Google Drive items are identified by their
|
||
file ID, so `provider_key` carries the ID (stable across renames and moves)
|
||
and `display_path` carries the folder path reported by the last listing.
|
||
`iter_items` walks the folder tree with `files.list` (metadata only) and
|
||
reports the `md5Checksum` — a content-addressed change token — via
|
||
`detect_changes`, so unchanged trees cost a listing walk and no file
|
||
transfer. Google-native documents (`application/vnd.google-apps.*`) have no
|
||
downloadable payload and are skipped. Drive has no presignable URL, so
|
||
`open_stream` always returns a proxied byte iterator with HTTP `Range`
|
||
support; freshness comes from scheduled syncs rather than filesystem
|
||
watching, as with the other remote providers.
|
||
|
||
#### Jellyfin provider
|
||
|
||
The `jellyfin` provider (`external/_jellyfin.py`, httpx) is the first
|
||
*entity-backed* provider: instead of importing remote files, it imports a
|
||
Jellyfin server's music library as first-class Songhive tracks, albums,
|
||
artists, and playlists. Each imported entity is anchored by an `ExternalItem`
|
||
row (`kind` + `provider_key` + etag/mtime/raw provider metadata) instead of
|
||
an `ExternalTrack`, so there is no sha256, write-back, rename, or delete
|
||
state — the provider is strictly read-only, and audio bytes stay on the
|
||
Jellyfin server.
|
||
|
||
A Jellyfin external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|----------------------|----------|---------|--------------------------------------------------------------------------|
|
||
| `server_url` | yes | — | Base URL of the Jellyfin server; SSRF-validated (public addresses only). |
|
||
| `api_key` | yes* | — | Jellyfin API key (Dashboard → API Keys). |
|
||
| `username`+`password`| yes* | — | Alternative to `api_key`; exchanged for a session token once. |
|
||
| `verify_ssl` | no | `true` | Verify the server's TLS certificate. |
|
||
| `timeout` | no | `30` | HTTP request timeout in seconds. |
|
||
| `collections` | no | `[]` | Jellyfin library names/ids to import; empty means all music libraries. |
|
||
| `include_tracks` | no | `true` | Import `Audio` items as tracks. |
|
||
| `include_artists` | no | `true` | Import `MusicArtist` items as artists. |
|
||
| `include_albums` | no | `true` | Import `MusicAlbum` items as albums. |
|
||
| `include_playlists` | no | `true` | Import `Playlist` items as provider-owned playlists. |
|
||
| `sync_metadata` | no | `false` | Run Songhive-side MusicBrainz enrichment on imported tracks. |
|
||
| `sync_cover_art` | no | `true` | Reference Jellyfin image URLs as artwork (never downloaded). |
|
||
| `dedup_musicbrainz` | no | `false` | Match artists/albums by MusicBrainz id before name matching (DB-only). |
|
||
|
||
*Exactly one auth path is required: `api_key` (preferred) or
|
||
`username`+`password`.
|
||
|
||
Tracks enumerate `/Items?IncludeItemTypes=Audio&Recursive=true` with
|
||
`Fields=Etag,DateModified,Path,Genres,MediaSources,ProviderIds` and an
|
||
incremental date filter when the previous run supplies `since`; albums,
|
||
artists, and playlists enumerate the matching `IncludeItemTypes`, and
|
||
playlists additionally fetch `/Playlists/{id}/Items` to preserve order.
|
||
Imported entities deduplicate by normalized name (or MusicBrainz id when
|
||
`dedup_musicbrainz` is set — a pure database lookup that never calls the
|
||
MusicBrainz API). Provider-owned albums/artists are deleted on reconciliation
|
||
only when no other provider or non-provider content still references them;
|
||
provider-owned playlists are removed with the library. `raw_metadata` keeps
|
||
the provider's own fields (genre list, cover URL, MusicBrainz ids, stream
|
||
`Size`) so entity tracks carry no local file state.
|
||
|
||
Playback resolves `GET /Audio/{item_id}/stream` at play time and returns the
|
||
URL with `safe_to_redirect=False`, so Songhive always proxies it and forwards
|
||
the client's `Range` header — the API key never reaches the browser.
|
||
`write_tags`, `rename_source`, `delete_source`, and `compute_hash` are
|
||
unsupported and raise `UnsupportedExternalOperation`.
|
||
|
||
#### TIDAL provider
|
||
|
||
The `tidal` provider (`external/_tidal/`, `tidalapi` + a throttled raw-JSON
|
||
client) imports a subscriber's TIDAL collection — saved tracks, artists,
|
||
albums, and playlists — as first-class Songhive entities. It is entity-backed
|
||
(`entity_import`), declares `immutable_tracks` (provider metadata is
|
||
write-once), `lazy_contents` for playlists and albums (contents are fetched on
|
||
demand, never eagerly during sync), `editable_fields: ["genres", "tags"]`
|
||
(everything else is provider-owned), `federate_audio: false` (federated
|
||
objects carry metadata and a tidal.com link only — proxied audio bytes never
|
||
leave the instance), and `search`.
|
||
|
||
**Authentication.** TIDAL credentials come from the device-authorization
|
||
framework (`external/device_auth.py`): the SPA calls
|
||
`POST /external-libraries/device-auth/begin`, the user approves a short code
|
||
on TIDAL's link page, and the SPA polls `/device-auth/poll` until granted;
|
||
`POST /device-auth/complete` then returns the credential fragment merged into
|
||
the library config. A PKCE variant (`mode: "pkce"`, stored server-side code
|
||
verifier, user pastes the final redirect URL) is required for
|
||
`HI_RES_LOSSLESS` streaming — `effective_quality` silently downgrades to
|
||
`LOSSLESS` when `is_pkce` is false. Access/refresh tokens, `is_pkce`,
|
||
`user_id`, `country_code`, and `expiry_time` live in the encrypted library
|
||
config; sessions are cached in Redis and refreshed transparently.
|
||
|
||
**Catalog cache.** `services/provider_catalog.py` maintains
|
||
`provider_catalog_entries` — instance-wide raw provider payloads keyed by
|
||
`(provider_type, kind, provider_key)`. Track entries are write-once
|
||
(`expires_at = NULL`): TIDAL track metadata is treated as immutable by id, so
|
||
materializing a known id never hits the API, even across users. The cache is
|
||
never exposed through the API and can be force-refreshed with
|
||
`songhive admin tidal-refresh-catalog (--track ID | --album ID | --artist ID | --playlist UUID | --expired)`.
|
||
|
||
**Lazy contents.** Playlist and album items are not synchronized recursively.
|
||
`ExternalItem.contents_fetched_at`/`contents_error` track freshness; opening a
|
||
provider-backed playlist or album (`GET /playlists/{id}` or
|
||
`/albums/{id}` returns `provider_sync`) calls
|
||
`external/lazy.ensure_contents`, which enqueues `refresh_external_contents`
|
||
(Celery) behind a per-item Redis lock when the TTL
|
||
(`playlist_ttl_seconds`, instance floor 300s) has elapsed. The task fetches
|
||
the ordered track refs (etag-conditional when supported), rewrites
|
||
`PlaylistTrack` rows, materializes missing tracks as
|
||
`membership="referenced"` (full syncs reconcile by membership so referenced
|
||
tracks untouched by the collection are demoted rather than deleted), and
|
||
publishes an `external_contents_refreshed` WebSocket event so the frontend
|
||
reloads. A provider 404 is terminal, not retried: the item is marked
|
||
`state="missing"`, its catalog row pinned `unavailable_at`, and
|
||
`ensure_contents` stops auto-enqueueing until a listing sync re-activates
|
||
the item. `POST /playlists/{id}/provider-sync` (and the album equivalent)
|
||
forces a refresh for owners/managers.
|
||
|
||
**Streaming and downloads.** `external/_tidal/stream.py` resolves playback at
|
||
request time: BTS qualities return a redirectable CDN URL (or a proxied
|
||
stream when `redirect_streams` is off); `HI_RES_LOSSLESS` manifests arrive as
|
||
MPEG-DASH — `mpd_mode = "segments"` serves raw segment URLs, `"remux"` feeds
|
||
them through `external/_tidal/remux.py` into the reusable
|
||
`services/remote_audio_cache.py` ffmpeg remux cache (bounded by the instance's
|
||
`remote_cache_dir`/`remote_cache_max_bytes`/`remote_cache_retention_seconds`
|
||
settings). The per-library
|
||
`stream_policy` limit gates every stream entry point through
|
||
`services/streaming.py`. Downloads (`download_format = "flac" | "aac"`) are
|
||
disabled unless `external_libraries.tidal.allow_downloads` is set in the
|
||
instance config.
|
||
|
||
**Metadata edits.** `limits["editable_fields"]` is enforced by
|
||
`api/routes/_common.enforce_editable_fields` on the track/album/artist update,
|
||
image, tag, and genre endpoints; responses expose `editable_fields` and
|
||
`external_provider_type` so the SPA locks provider-owned inputs while keeping
|
||
tags, genres, and visibility editable.
|
||
|
||
**Search.** `adapter.search` resolves `tidal.com` URLs directly —
|
||
`mapping.parse_tidal_url` accepts both the canonical `/browse/{kind}/{id}`
|
||
shape and the share-link `/{kind}/{id}` shape (track, album, artist,
|
||
playlist) on `tidal.com` and its subdomains, and the entity is fetched by
|
||
id instead of text-searched. Non-TIDAL URLs and unsupported kinds
|
||
(`mix`, `video`, ...) return no results; plain text goes through the
|
||
catalog `search` endpoint.
|
||
|
||
A TIDAL external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|------------------------------|----------|--------------|----------------------------------------------------------------|
|
||
| `access_token`/`refresh_token` | yes* | — | OAuth credentials; populated by the Connect flow. |
|
||
| `is_pkce` | no | `false` | Set by the PKCE connect flow; gates `HI_RES_LOSSLESS`. |
|
||
| `user_id`/`country_code` | auto | — | Account identity captured during authorization. |
|
||
| `session_id`/`token_type`/`expiry_time` | auto | — | Session bookkeeping refreshed transparently. |
|
||
| `quality` | no | `LOSSLESS` | `LOW`, `HIGH`, `LOSSLESS`, or `HI_RES_LOSSLESS` (PKCE only). |
|
||
| `mpd_mode` | no | `segments` | `segments` or `remux` for MPEG-DASH (Hi-Res) delivery. |
|
||
| `redirect_streams` | no | `true` | Redirect clients to TIDAL CDN URLs instead of proxying bytes. |
|
||
| `download_format` | no | `flac` | `flac` or `aac`; requires `allow_downloads` in instance config.|
|
||
| `include_tracks`/`include_artists`/`include_albums`/`include_playlists` | no | `true` | Which saved collections to import. |
|
||
| `include_followed_playlists` | no | `false` | Also import followed (not owned) playlists. |
|
||
| `playlist_ttl_seconds` | no | `21600` | Lazy contents cache TTL; instance floor is 300s. |
|
||
| `sync_metadata` | no | `false` | Run MusicBrainz enrichment on imported tracks. |
|
||
| `dedup_isrc` | no | `false` | Match imported tracks by ISRC before creating new rows. |
|
||
| `max_requests_per_second` | no | provider | Throttle for raw API calls. |
|
||
| `request_timeout_seconds` | no | provider | HTTP timeout for API calls and token refresh (default 30s). |
|
||
|
||
*Credentials are supplied by the device-auth/PKCE connect flow rather than
|
||
entered manually.
|
||
|
||
#### YouTube provider
|
||
|
||
The `youtube` provider (`external/_youtube/`, `ytmusicapi` + `yt-dlp` +
|
||
a raw YouTube Data API v3 client) imports a Google account's liked/saved
|
||
videos, playlists, channel subscriptions and — for YouTube Music Premium
|
||
accounts — saved albums. It is entity-backed (`entity_import`) and shares
|
||
the TIDAL capability set: `immutable_tracks`, `lazy_contents` for playlists
|
||
and albums, `editable_fields: ["genres", "tags"]`,
|
||
`federate_audio: false` (federated objects carry metadata and a
|
||
youtube.com link only), and `search`.
|
||
|
||
**API surface.** Which upstream API a library uses is resolved once per
|
||
credential set and cached in Redis for an hour: `music` (ytmusicapi's
|
||
innertube endpoints) for Premium accounts and browser sessions, `youtube`
|
||
(Data API v3 under the OAuth Bearer token) otherwise. `auto` mode probes
|
||
`YTMusic.get_account_menu` for a Premium entry; `music`/`youtube` can be
|
||
pinned per library via `api_mode`.
|
||
|
||
**Authentication.** The device-authorization flow
|
||
(`external/device_auth.py`) wraps Google's OAuth device grant for
|
||
TV/limited-input clients — the SPA shows the user code, the user approves
|
||
on `google.com/device`, and tokens (`access_token`/`refresh_token`/
|
||
`expiry_time`) land in the encrypted library config, refreshed
|
||
transparently through a Redis-cached, per-account locked refresh. It needs
|
||
an instance-level OAuth client of the "TVs and limited input devices" type
|
||
(`external_libraries.youtube.client_id`/`client_secret`) with the YouTube
|
||
Data API v3 enabled. Without one — or for cookie-only sessions — users
|
||
paste the request headers of a logged-in `music.youtube.com` browser
|
||
session into `request_headers` (or a Netscape export into `cookies`);
|
||
`external/_youtube/cookies.py` normalizes either shape to a Netscape cookie
|
||
file that is handed to yt-dlp on every extraction, so member/age-restricted
|
||
videos resolve like the user's own session. Both fields are credential
|
||
material and are redacted from API responses by the secret-name heuristic
|
||
(`services/secrets.py` also treats `cookie`/`header` keys as secret).
|
||
|
||
**Streaming.** There are no local bytes: at play time
|
||
`external/_youtube/ytdlp.py` extracts the video's format list and resolves
|
||
a signed `googlevideo.com` URL, cached in Redis for
|
||
`stream_url_ttl_seconds` (never past the URL's own expiry). The default
|
||
rendition is best audio-only (`bestaudio[ext=m4a]/bestaudio/best`);
|
||
`GET /api/v1/stream/{id}?variant=video` selects the `video_format`
|
||
selector (video+audio) when `external_libraries.youtube.video_playback`
|
||
allows it — `ExternalItemRef.variant` carries the choice through
|
||
`services/streaming.py` to `open_stream`. Streams stay `kind="url"` with
|
||
`safe_to_redirect=False` (googlevideo URLs are bound to the session
|
||
cookies/IP) and the `stream_policy` limit gates them like TIDAL. The SPA
|
||
exposes the video rendition through the reusable
|
||
`components/player/VideoEmbedPlayer.vue` on the track page's "Watch video"
|
||
action. Downloads (`download_format = "audio" | "video"`) run yt-dlp into
|
||
the stream temp dir and are disabled unless
|
||
`external_libraries.youtube.allow_downloads` is set.
|
||
|
||
**Search.** `adapter.search` resolves `youtube.com`/`youtu.be` URLs (and
|
||
bare video ids) directly — falling back to a yt-dlp extraction when the
|
||
active API surface can't address the entity — and forwards other text to
|
||
ytmusicapi's catalog search.
|
||
|
||
A YouTube external library stores the following adapter config:
|
||
|
||
| Key | Required | Default | Description |
|
||
|------------------------------|----------|--------------|----------------------------------------------------------------|
|
||
| `access_token`/`refresh_token`/`expiry_time` | yes* | — | OAuth credentials; populated by the Connect flow. |
|
||
| `client_id`/`client_secret` | auto | — | OAuth client pair that issued the tokens (recorded at grant). |
|
||
| `request_headers`/`cookies` | alt* | — | Pasted browser session (headers text or Netscape export). |
|
||
| `auth_mode` | auto | — | `oauth` or `browser`; inferred from stored credentials. |
|
||
| `account_name`/`channel_handle` | auto | — | Account identity captured after authorization. |
|
||
| `api_mode` | no | `auto` | `auto`, `music`, or `youtube`. |
|
||
| `include_tracks`/`include_playlists`/`include_subscriptions`/`include_albums` | no | `true` | Which collections to import (albums need `music` mode). |
|
||
| `download_format` | no | `audio` | `audio` or `video`; requires `allow_downloads` in instance config. |
|
||
| `playlist_ttl_seconds` | no | `21600` | Lazy contents cache TTL; instance floor is 300s. |
|
||
| `video_format` | no | 720p default | yt-dlp selector for the video+audio rendition. |
|
||
| `sync_metadata` | no | `false` | Run MusicBrainz enrichment on imported tracks. |
|
||
| `max_requests_per_second` | no | provider | Throttle for YouTube API calls. |
|
||
| `request_timeout_seconds` | no | provider | HTTP timeout for API calls and token refresh (default 30s). |
|
||
|
||
*Either OAuth credentials or a pasted browser session is required; the
|
||
device-auth flow supplies the former.
|
||
|
||
#### Visibility, sharing, and secret redaction
|
||
|
||
Every external library is backed by a normal `Library` row, so visibility
|
||
(`private`/`local`/`public`) and `ShareGrant` sharing behave exactly like
|
||
regular libraries. `PATCH /api/v1/external-libraries/{id}` (and the admin
|
||
equivalent) accepts a `visibility` field; changing it updates the backing
|
||
library and calls `services.music.propagate_external_library_visibility`,
|
||
which rewrites the visibility of all linked synced tracks so list queries
|
||
and single-track ACLs stay consistent. `PATCH /api/v1/libraries/{id}` on an
|
||
external-backed library propagates the same way. Non-admin propagation only
|
||
touches tracks the caller owns, mirroring album visibility propagation.
|
||
|
||
Because API responses redact secret-bearing config keys to `"<redacted>"`,
|
||
both PATCH routes run submitted configs through
|
||
`_merge_config_preserving_redacted`: any key still carrying the sentinel is
|
||
restored from the stored (decrypted) config before validation and
|
||
re-encryption, so editing non-secret fields never clobbers credentials.
|
||
|
||
The web UI renders a per-provider configuration form instead of raw JSON.
|
||
Provider form templates live in
|
||
`frontend/src/config/externalLibraryProviderTemplates.ts`. Each template entry
|
||
lists the JSON property name, i18n label/description keys, field type (string,
|
||
password, number, boolean, enum, comma-separated string array, or multiline
|
||
textarea), and default value. A field may also declare a `configKey` to
|
||
read/write a different JSON property than its own name — used for union-typed
|
||
keys such as WebDAV's `verify_ssl`, where the optional CA-bundle text field
|
||
shares the key with the verification toggle and overrides it when non-empty.
|
||
`ExternalLibraryEditView` switches the displayed fields whenever the provider
|
||
`<select>` changes, and falls back to a plain JSON textarea for providers that do
|
||
not have a template yet. Template forms only emit their declared fields, so on
|
||
update the view also echoes back every stored config key the template doesn't
|
||
manage (`carriedStoredConfig`) — device-auth/OAuth credential fragments such as
|
||
`access_token`, `user_id` or `auth_mode` would otherwise be dropped by the
|
||
PATCH's full-config replacement and the save would fail validation.
|
||
|
||
Sync and tasks:
|
||
|
||
- `tasks/external_libraries.py` defines three Celery tasks:
|
||
- `sync_external_library_task` runs a manual or scheduled sync, enumerating
|
||
provider items through `external/sync.py` and upserting `ExternalTrack`
|
||
rows for file providers or `ExternalItem`-backed entities for
|
||
entity-backed providers.
|
||
- `scan_scheduled_syncs_task` scans for due external libraries and enqueues
|
||
sync tasks while skipping libraries with an active run.
|
||
- `write_back_metadata_task` applies local metadata edits to the provider when
|
||
the adapter supports `write_tags`.
|
||
- `rename_source` lets track owners and admins rename the backing source file
|
||
from the track edit page when the adapter supports it.
|
||
- Destructive source deletion requires both `write_tags` and `delete_source`
|
||
capabilities, the global `allow_destructive_delete` setting, and an explicit
|
||
`confirm: "DELETE"` from the caller. Tracks may optionally be removed from
|
||
Songhive at the same time.
|
||
|
||
Streaming and downloads:
|
||
|
||
- `services/streaming.resolve_external_stream` loads an external track
|
||
(`ExternalTrack` for file providers, `ExternalItem` for entity-backed
|
||
providers) and opens a provider stream (`path`, `iterator`, or `url`).
|
||
- `streaming/handler.py` falls back from local `StoredFile` playback to external
|
||
streams, preserving auth, ACL, history, and now-playing broadcasts.
|
||
- `api/routes/tracks.py` provides `GET /api/v1/tracks/{track_id}/download`,
|
||
proxying external bytes through Songhive and honoring `Content-Disposition`
|
||
and `disposition=inline|attachment`.
|
||
- `services/streaming.collect_external_stream` materializes iterator/url
|
||
streams into memory or a temp file. Audio-typed payloads in MP4 containers
|
||
get their `ftyp` major brand rewritten to `M4A ` — audio-only MP4s often
|
||
carry a generic `isom`/`mp4*` brand that content sniffers (libmagic, and
|
||
therefore Fediverse media fetchers such as Mastodon) report as
|
||
`video/mp4` regardless of the served `Content-Type`, which remote servers
|
||
then process (and reject) as video. Non-temporary `path` streams are never
|
||
rewritten — they reference files owned by the adapter (e.g. the local
|
||
library). The same caveat applies to local `StoredFile` downloads, which
|
||
are served straight from storage and not normalized.
|
||
|
||
Secrets and audit:
|
||
|
||
- `services/secrets.py` encrypts/decrypts provider configs at rest and redacts
|
||
them in API responses and audit logs.
|
||
- All mutating external-library and external-track endpoints log an `AuditLog`
|
||
row with a sanitized `details` dictionary.
|
||
|
||
### Duplicate detection
|
||
|
||
When an uploaded audio file's audio-only SHA-256 hash matches an `ExternalTrack`
|
||
in `active` or `shadowed` state, `services/import_.py` raises an
|
||
`ExternalDuplicateError`. The `POST /api/v1/files/upload` and
|
||
`POST /api/v1/files/upload/bulk` endpoints return a 409 response with an
|
||
`ExternalDuplicateWarning` containing a short-lived Redis token.
|
||
The caller can then resolve the conflict via
|
||
`POST /api/v1/files/upload/resolve-duplicate` with `action=keep_local` (create a
|
||
local Songhive track and keep the uploaded file) or `action=discard_upload`
|
||
(reuse the existing external track and remove the uploaded stored file).
|
||
|
||
---
|
||
|
||
## API Adapters (Subsonic)
|
||
|
||
Besides the native `/api/v1` surface, Songhive can expose *foreign* media APIs
|
||
so third-party clients can browse and stream its content. These integrations
|
||
live in `songhive/adapters/` — a different adapter family from
|
||
`songhive/external/` (which *imports* remote libraries into Songhive). API
|
||
adapters map Songhive's services and ACLs onto an external protocol; the
|
||
first implementation is Subsonic/OpenSubsonic, and the registry is designed
|
||
for future Icecast/Mopidy/Jellyfin adapters.
|
||
|
||
Framework (`songhive/adapters/`):
|
||
|
||
- `base.py` — `APIAdapter` interface: `name`, `is_enabled(config)`,
|
||
`router()` (FastAPI routes), `tornado_routes()` (native handlers for
|
||
streaming), `include(app, config)`.
|
||
- `registry.py` — name-keyed registry (`register_adapter`, `get_adapter`,
|
||
`list_adapters`), `mount_adapters(app, config)` invoked from `create_app`,
|
||
and `adapter_tornado_routes(config)` consumed by `_build_tornado_app`
|
||
ahead of the WSGI fallback.
|
||
- Each adapter owns its complete URL namespace (Subsonic owns `/rest`).
|
||
|
||
Subsonic adapter (`songhive/adapters/subsonic/`):
|
||
|
||
- **Namespace** — `/rest/*.view`, the path Subsonic clients hard-code.
|
||
`ping`, `getLicense`, `getMusicFolders`, browsing (`getIndexes`,
|
||
`getArtists`, `getArtist`, `getAlbum`, `getSong`, `getMusicDirectory`,
|
||
`getArtistInfo*`, `getAlbumInfo*`), `getAlbumList*`, `getGenres`,
|
||
`getSongsByGenre`, `getRandomSongs`, `search*` variants, playlists
|
||
(`getPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`,
|
||
`deletePlaylist`), starring (`getStarred*`, `star`, `unstar`),
|
||
`scrobble`, `nowPlaying`, `getNowPlaying`, `stream`, `download`,
|
||
`getCoverArt`, `getAvatar`, plus an OpenSubsonic
|
||
`getOpenSubsonicExtensions` advertisement and a `/rest/{method}.view`
|
||
catch-all that returns a protocol error for unimplemented methods.
|
||
- **Envelope** — every endpoint answers with the `subsonic-response`
|
||
envelope (protocol `1.16.1`, `type=songhive`, `openSubsonic=true`) in XML
|
||
by default, JSON via `f=json`, or JSONP via `f=jsonp`+`callback`. JSONP
|
||
callbacks are restricted to safe identifier paths to prevent script
|
||
injection.
|
||
- **Authentication** — per-request `u`+`p` (plain, `enc:`-hex, or a Songhive
|
||
API token in `p`), `u`+`apiKey` (OpenSubsonic), and `u`+`t`/`s`
|
||
salted-token auth. Salted tokens (`md5(password + salt)`) can only be
|
||
verified against credentials the server can reconstruct, so the real
|
||
password is `p`-only — but API tokens work: HS256 JWTs are deterministic
|
||
given their claims, and every claim is recoverable from the `api_tokens`
|
||
row (`user_id`, `jti`, `expires_at`, plus `created_at` which
|
||
`issue_api_token` pins to the `iat` claim). `auth.py` rebuilds each
|
||
candidate JWT byte-for-byte (trying `created_at` ±1s for tokens issued
|
||
before the pin) and compares `md5(jwt + s)` to `t`, so salted-token
|
||
clients work with an API token as the password while no usable
|
||
credential is stored. Because credentials ride in request parameters
|
||
rather than cookies, `/rest/` is exempt from the CSRF middleware.
|
||
- **ACL mapping** — every browse/stream path goes through the same
|
||
`services.acl` checks as `/api/v1`: anonymous users see public content,
|
||
authenticated users see public/local/owned/shared content, inaccessible
|
||
items map to error 50 (or 70 where the spec calls for not-found).
|
||
- **Streaming** — under the Tornado bootstrap `/rest/stream.view` and
|
||
`/rest/download.view` are served natively by
|
||
`handlers.py` (subclasses of `StreamHandler`, reusing its range,
|
||
transcoding and external-stream logic with Subsonic auth/error mapping).
|
||
`media.py` registers the same endpoints on the FastAPI router as the
|
||
uvicorn fallback; `getCoverArt`/`getAvatar` are FastAPI-only.
|
||
- **Playback reporting** — `scrobble` records listens via
|
||
`services.streaming.record_listen`; `nowPlaying`/`getNowPlaying` share an
|
||
in-process `now_playing.py` registry (also populated when tracks are
|
||
streamed). Mutating playlist endpoints write `AuditLog` rows through
|
||
`services.audit.log_action`.
|
||
- **Config** — `subsonic.enabled` (default `true`) gates mounting; nginx
|
||
proxies `/rest/` with `proxy_buffering off` for streaming.
|
||
|
||
---
|
||
|
||
## Database Migrations
|
||
|
||
Schema changes are managed with [Alembic](https://alembic.sqlalchemy.org/). The
|
||
``songhive/migrations/`` package contains the Alembic environment, the
|
||
``versions/`` directory, and an empty ``base`` revision that marks the pre-
|
||
migration schema baseline. New installs receive the current schema from
|
||
``Base.metadata.create_all`` and are then stamped at ``head``; existing
|
||
production databases are stamped at ``base`` and upgraded normally.
|
||
|
||
Migrations run automatically when ``create_app`` is called, and the Docker
|
||
entrypoint runs ``songhive admin migrate`` before starting the web server or
|
||
Celery workers. ``ensure_migrated`` acquires a backend-specific lock for the
|
||
whole operation: a PostgreSQL advisory lock for Postgres, or a ``fcntl`` file
|
||
lock on a companion file for SQLite. This prevents the web server and worker
|
||
from racing to create the baseline schema on a fresh Docker Compose install.
|
||
Admins can also trigger them manually:
|
||
|
||
```bash
|
||
python -m songhive admin migrate
|
||
```
|
||
|
||
To autogenerate a revision after a model change (from the repository root,
|
||
with ``SONGHIVE_DATABASE__URL`` set or ``database.url`` configured):
|
||
|
||
```bash
|
||
alembic revision --autogenerate -m "add example column"
|
||
```
|
||
|
||
---
|
||
|
||
## Authentication & Authorization
|
||
|
||
### Authentication flow
|
||
|
||
1. **Login** — username/password → bcrypt verify → issue `TokenPair`
|
||
(short-lived JWT access token + long-lived opaque refresh token stored in
|
||
Redis). Each refresh token also records IP, user agent, creation time and
|
||
expiry, forming a user session. The JSON response still carries the token
|
||
pair, but the server also sets three cookies (`api/cookies.py`):
|
||
`access_token` and `refresh_token` are `HttpOnly` so browser JavaScript
|
||
never reads the credentials, while a readable `csrf_token` backs the
|
||
double-submit CSRF check. The refresh cookie is scoped to
|
||
`Path=/api/v1/auth`; cookie flags are controlled by `auth.cookie_secure`
|
||
(defaults to secure outside debug mode), `auth.cookie_samesite` (`lax`),
|
||
and `auth.cookie_domain`.
|
||
2. **Refresh** — opaque refresh token from the JSON body (API clients) or the
|
||
`refresh_token` cookie (browsers) → Redis lookup → rotate (revoke old,
|
||
issue new pair) and re-set the cookies. Failed refreshes clear the cookies.
|
||
3. **Revoke** — single token or all tokens for a user (Redis key deletion).
|
||
Access tokens now carry a `jti` claim, and revocation also adds that JTI to
|
||
a Redis deny-list. The JWT middleware checks the deny-list so revoked
|
||
sessions cannot continue using their existing access token until expiry.
|
||
4. **Session management** — users can list their active refresh-token sessions
|
||
(including the current one) and revoke any session individually, revoking the
|
||
current session ends the user's login.
|
||
5. **OAuth2** — authlib `authorization_code` flow for third-party app access.
|
||
6. **Invite-only registration** — controlled by `RegistrationMode` config
|
||
setting; `Invite` codes with optional `max_uses` and `expires_at`.
|
||
|
||
### Authorization
|
||
|
||
- **JWT middleware** — `api/middleware/auth.py` decodes the bearer token
|
||
(`Authorization: Bearer …` first, then the `access_token` cookie) and
|
||
injects the current `User` via FastAPI's dependency system. Cookie auth is
|
||
what lets `<img>`/`<audio>` elements, file downloads, and the WebSocket
|
||
handshake authenticate without JavaScript-accessible tokens.
|
||
- **CSRF** — `api/middleware/csrf.py` enforces a double-submit check: unsafe
|
||
methods (POST/PUT/PATCH/DELETE) that carry an auth cookie and no
|
||
`Authorization` header must echo the readable `csrf_token` cookie in
|
||
`X-CSRF-Token`. Session endpoints (login/refresh/logout/registration, OAuth
|
||
token endpoints) are exempt; bearer clients and safe methods are unaffected.
|
||
- **Role-based** — `UserRole.ADMIN` / `MODERATOR` / `USER`; `require_admin`
|
||
dependency enforces admin-only routes.
|
||
- **ACL service** (`services/acl.py`) — three-level visibility check augmented
|
||
with `ShareGrant` (owner grants a named user) and `ShareToken` (revocable
|
||
short-link cookie). `Artist` rows carry no `visibility`/`owner_id` of their
|
||
own, so artist listings (`GET /api/v1/artists/` and the artists section of
|
||
`GET /api/v1/search/`) instead require at least one track or album the
|
||
requester can access; artists whose content is entirely private or unshared
|
||
are hidden. Admins bypass the filter. `GET /api/v1/artists/` also accepts
|
||
`owner_username` (like the album/track/library/playlist listings) to keep
|
||
only artists with at least one track or album owned by that user, on top of
|
||
the requester's ACL. `StoredFile` rows inherit access from
|
||
the entities referencing them: a file attached to a track, album, playlist,
|
||
or library is downloadable by anyone who can access that entity (directly,
|
||
via a `ShareGrant`, or via a `ShareToken`). Album covers are also reachable
|
||
through the album's tracks, and artist images through the artist's tracks
|
||
and albums, so sharing a track reveals its album cover and artist image.
|
||
- **Rate limiting** — Redis sliding-window; `rate_limit` (IP), `rate_limit_user_or_ip`
|
||
(authenticated users keyed by id), and `rate_limit_account` (always per-user)
|
||
FastAPI dependencies. Media `DELETE` endpoints use `rate_limit_account` for
|
||
per-user rate limiting on destructive operations. Byte-serving endpoints
|
||
(`GET /api/v1/files/{id}/download`, `GET /api/v1/tracks/{id}/download`) use
|
||
`rate_limit_file_download`/`rate_limit_media`, which draw from the looser
|
||
`auth.rate_limit_media_requests` budget (0 disables it) instead of
|
||
`rate_limit_requests`; `image/*` stored files are exempt entirely so
|
||
avatars, covers and image attachments survive federation-driven fetch
|
||
bursts from a single remote egress IP. Fails open when Redis is
|
||
unavailable. Downloaded files are content-addressed, so their responses
|
||
carry `Cache-Control: …, max-age=31536000, immutable` — `public` for
|
||
publicly visible files, `private` otherwise.
|
||
- **CORS** — the API uses the credentialed `CORSMiddleware` allowlist from
|
||
`server.cors_origins`. Read-only media endpoints
|
||
(`GET /api/v1/files/{id}/download`, `GET /api/v1/tracks/{id}/download`, and
|
||
`GET /api/v1/stream/{id}`) instead return `Access-Control-Allow-Origin: *`
|
||
via `api/middleware/media_cors.py` (and `set_default_headers` in the Tornado
|
||
`StreamHandler`) so remote Fediverse clients can embed audio directly — this
|
||
is safe because the wildcard can never be combined with credentials, and the
|
||
`access_token` cookie is `SameSite=Lax` so it is not sent cross-origin.
|
||
Preflights on those paths allow the `Range` header and expose
|
||
`Content-Range`/`Accept-Ranges`/`Content-Length` to fetch-based players.
|
||
|
||
### Owner exposure
|
||
|
||
Entity detail responses for `Track`, `Album`, `Library`, and `Playlist` include `owner_id` whenever the requester has ACL access to the entity; `?include=owner` adds a nested `UserSummary` with `actor_url`, `username`, `display_name`, and `avatar_url`. The frontend `useEntityMeta` composable returns this full owner object, and `UserLink` routes local users to `/@{username}` and remote users to their `actor_url`.
|
||
|
||
### Profile visibility
|
||
|
||
Each user carries a `profile_visibility` preference (`public`, `local`, or
|
||
`private`; default `public`, editable from `/settings` via
|
||
`PATCH /api/v1/users/me`) controlling whether their profile appears in the
|
||
users directory. `GET /api/v1/users` and the users section of
|
||
`GET /api/v1/search/` hide `local` profiles from anonymous callers and never
|
||
list `private` ones — not even to their owner. Individual profile pages
|
||
(`GET /api/v1/users/{username}`, `/@{username}`) remain reachable regardless
|
||
of the setting.
|
||
|
||
---
|
||
|
||
## File Storage & Upload Pipeline
|
||
|
||
1. Client lists visible files via `GET /api/v1/files/`, uploads via
|
||
`POST /api/v1/files/upload`, or bulk-uploads via
|
||
`POST /api/v1/files/upload/bulk`. The bulk endpoint uses the same per-IP
|
||
rate limit as the single-file upload and enforces per-request limits on the
|
||
number of files and total request size.
|
||
2. `StorageService` (facade over `StorageBackend`) validates size limit,
|
||
computes SHA-256, deduplicates by hash, writes to backend.
|
||
3. A `StoredFile` row is created (content-addressable, owner/visibility set).
|
||
4. An `Upload` row is created linking the stored file to a track (or pending
|
||
import).
|
||
5. A Celery `import_` task processes metadata, creates/updates `Track`,
|
||
`Album`, `Artist` records, and optionally triggers `enrich_track`.
|
||
6. `enrich_track` enqueues `sync_track_tags` and `enrich_images` on success;
|
||
the latter fetches artist images and any missing album covers.
|
||
7. A separate Celery `transcoding` task pre-transcodes to requested formats
|
||
and stores results as `TranscodedFile` rows pointing back to a `StoredFile`.
|
||
|
||
**Orphan GC** — the `storage.cleanup_orphaned_files` Celery task runs on the
|
||
configured crontab (default: daily at 03:00) and deletes `StoredFile` rows
|
||
(and their backing files) not referenced by any `Track`, `Album`, `Upload`,
|
||
or `DownloadArchive` (archive-backed ZIPs are protected until the archive
|
||
row itself is cleaned up).
|
||
|
||
**Cascade Deletion** — `services/deletion.py` provides centralized deletion
|
||
logic for `Track`, `StoredFile`, `Album`, `Artist`, `Playlist`, and `Library`.
|
||
`DELETE` endpoints on the corresponding routes accept a `recursive` query
|
||
parameter; albums default to recursive deletion while other collections default
|
||
to non-recursive. A dedicated `DELETE /api/v1/tracks/bulk` endpoint accepts a
|
||
list of track IDs and delegates to `delete_tracks_bulk` in the deletion service,
|
||
applying the same ACL and rate-limiting checks as single-track deletion.
|
||
Deleting a track removes its `Upload`, `LibraryTrack`,
|
||
`PlaylistTrack`, `Favorite`, `ListeningHistory`, `TranscodedFile`, `ShareGrant`,
|
||
`ShareToken`, and `Report` rows and deletes the underlying `StoredFile` once it
|
||
is unreferenced. Deleting a stored file removes all tracks that use it as their
|
||
audio source and clears `cover_file_id`/`image_file_id` references on
|
||
albums/artists before removing the backing object. Deleting an external library
|
||
cascades through its linked `ExternalTrack` rows, removing each Songhive track
|
||
and its dependents (playlist entries, favorites, etc.), cleaning up empty
|
||
albums/artists, and removing the underlying `Library` only when it is empty.
|
||
Recursive deletion collects unpublish information for public tracks and enqueues
|
||
`Delete(Tombstone)` ActivityPub activities.
|
||
|
||
---
|
||
|
||
## Streaming & Transcoding
|
||
|
||
- **Tornado `StreamHandler`** (`streaming/handler.py`) handles
|
||
`GET /api/v1/stream/{track_id}`, resolves the best `StoredFile` via
|
||
`services/streaming.py`, and streams with native range-request support.
|
||
- **Transcode cache** — on first request for a (format, bitrate) combination,
|
||
the `Transcoder` writes the output to a temp file, which is then stored as
|
||
a `StoredFile` and indexed in `TranscodedFile`. Subsequent requests serve
|
||
the cached file directly (skipping ffmpeg) when `transcode_cache_enabled`.
|
||
- **Bitrate enforcement** — `effective_bitrate()` in `config/schema.py`
|
||
computes the minimum of: requested bitrate, instance max, and per-role max
|
||
(`max_bitrate_by_role` keyed by `User.role`).
|
||
- **Supported formats**: MP3, OGG (Vorbis), FLAC, AAC (M4A), Opus.
|
||
|
||
### Server-side audio outputs
|
||
|
||
Playback can be routed to persistent server-side outputs instead of the
|
||
browser Web Player, Spotify-Connect-style: the browser (or any client) sends
|
||
commands, but audio is rendered by the server, so playback continues after
|
||
the controlling tab closes.
|
||
|
||
- **Control plane** — a persisted `PlaybackSession` row
|
||
(`models/playback_session.py`) holds the queue, index, position, volume and
|
||
play/pause state per user. `services/playback.py` validates commands,
|
||
commits the new state, then publishes a control envelope on the Redis list
|
||
`songhive:playback:control:{session_id}`. Session changes are broadcast
|
||
over `/ws/events` (`playback_session` events) so multiple tabs stay in
|
||
sync.
|
||
- **Outputs** — an `OutputStream` row (`models/output_stream.py`) stores a
|
||
`provider_type` plus a provider config. Secret config fields are
|
||
Fernet-encrypted at rest and redacted to `"<redacted>"` in API responses;
|
||
PATCH routes merge the sentinel back to the stored value. Providers are
|
||
registered in `streams/registry.py` by subclassing `AudioOutput`
|
||
(`provider_type`, `FIELDS` schema hint, `validate_config`,
|
||
`create_driver`); `GET /api/v1/outputs/providers` exposes the schema to
|
||
the frontend form builder.
|
||
- **Stream worker** — `songhive stream-worker` (`streams/worker.py`) is a
|
||
dedicated process that scans for active sessions bound to outputs and runs
|
||
one `SessionDriver` per session. Each driver holds a Redis lock
|
||
`songhive:stream:lock:{output_id}` (unique token, refreshed every loop,
|
||
released on shutdown) so exactly one worker drives a given output. The
|
||
worker owns queue advancement, repeat/shuffle, listen recording,
|
||
scrobbling and idle shutdown (`streams.background_idle_timeout_seconds`).
|
||
A failed track advance (transient DB/storage/provider error) is retried a
|
||
few times before the driver falls back to its silence generator, and the
|
||
advance is idempotent — a retry after a committed index update re-syncs
|
||
the source instead of skipping a track. Deployed as the `stream-worker`
|
||
Compose service (`streams` profile) and `songhive-stream-worker.service`.
|
||
- **Icecast provider** (`streams/icecast.py`) — one long-lived ffmpeg
|
||
*encoder* pushes an MP3, Ogg Vorbis or Opus stream to an `icecast://`
|
||
mountpoint;
|
||
a short-lived ffmpeg *decoder* (`-re -ss <pos>`) feeds PCM into it per
|
||
track. Pausing swaps the decoder for a **realtime** (`-re`) `anullsrc`
|
||
silence generator so the mount stays alive without flooding listeners.
|
||
`source_ended` decoder events carry a generation tag so events from a
|
||
killed decoder can't pause the new source. ffmpeg's Icecast muxer cannot
|
||
update ICY metadata in flight, so `update_metadata` publishes the
|
||
now-playing title out-of-band to the server's
|
||
`/admin/metadata?mount=...&mode=updinfo&song=...` endpoint using the
|
||
source credentials (best-effort: failures are logged, never raised), and
|
||
the push is repeated after an encoder reconnect because a source
|
||
reconnect clears the mount's metadata.
|
||
- **Native HTTP provider** (`streams/http.py`) — same driver machinery, but
|
||
the encoder writes to `pipe:1` and a publish task XADDs each ~16 KiB chunk
|
||
(base64) into the Redis stream `songhive:stream:data:{mount}`
|
||
(`MAXLEN ≈ streams.http_stream_max_entries`). The Tornado
|
||
`StreamMountHandler` (`streaming/mount.py`) serves `GET /streams/{mount}`
|
||
by bursting the newest `streams.http_stream_burst_entries` entries
|
||
(`XREVRANGE`) then following the stream (`XREAD BLOCK`) — every listener
|
||
is an independent cursor, so Redis provides the fan-out and a stalled
|
||
client never slows the others. Entries older than
|
||
`streams.http_stream_max_lag_seconds` (entry IDs are server millisecond
|
||
timestamps) are skipped rather than delivered, so a listener that falls
|
||
behind jumps forward instead of accumulating latency, and the
|
||
`X-Accel-Buffering: no` response header keeps buffering proxies from
|
||
hiding listener lag in their own buffers. Liveness is the TTL'd
|
||
`songhive:stream:meta:{mount}` key, refreshed by the driver on a timer so
|
||
decoder gaps at track boundaries cannot expire it; `{"end": "1"}` entries
|
||
disconnect listeners on graceful stop and on encoder restart (a fresh
|
||
encoder produces a new container stream that cannot be spliced into an
|
||
open response); per-listener TTL keys under
|
||
`songhive:stream:listener:{mount}:*`, refreshed by the handler while
|
||
connected, feed `listener_count` for idle shutdown and enforce
|
||
`streams.http_stream_max_listeners` (0 = uncapped).
|
||
Now-playing metadata rides the same stream as `{"m": ...}` entries and is
|
||
mirrored into the meta blob; listeners that send `Icy-MetaData: 1` get an
|
||
`icy-metaint` header and `StreamTitle` blocks interleaved into the audio
|
||
every `streams.http_stream_metaint_bytes` bytes, so track changes update
|
||
the title in audio order. Track changes, driver starts and stops also emit
|
||
a `stream_update` event (`{mount, online, now_playing}`) on the WebSocket
|
||
fan-out channel so the `/streams` directory page updates live — public
|
||
mounts broadcast on the `streams` topic, while `listen_token` mounts are
|
||
private and the event is delivered only to the owner's connections (the
|
||
worker injects `OutputStream.user_id` into the driver config as
|
||
`_owner_user_id`). The page additionally polls `GET /api/v1/streams/`
|
||
every 30 s, both as the update path for anonymous visitors (the WS
|
||
endpoint requires authentication) and to reconcile states no event can
|
||
carry — a crashed driver only expires its meta key, and listener counts
|
||
change constantly. Mount slugs must be unique across `http`
|
||
outputs; an optional `listen_token` field gates listeners via
|
||
`?token=`/`Bearer`. No external server or extra port is needed.
|
||
- **Snapcast provider** (`streams/snapcast.py`) — same driver machinery, but
|
||
the "encoder" is an ffmpeg *passthrough*: raw s16le stereo PCM from stdin is
|
||
copied unchanged to a snapserver sink — either the named pipe of a
|
||
snapserver `pipe://` source (`mode=fifo`, auto-created with `mkfifo`,
|
||
existing non-FIFO paths rejected so a typo can't clobber a regular file) or
|
||
a snapserver `tcp://` listening source (`mode=tcp`), which also allows a
|
||
remote snapserver. Snapcast streams are never registered dynamically: the
|
||
source must already be declared in `snapserver.conf` (e.g.
|
||
`stream = tcp://0.0.0.0:4953?name=...&sampleformat=...`), the target `port`
|
||
is that source's listener — *not* the snapclient port 1704 — and
|
||
`sample_rate` must match the source's `sampleformat`. `listener_count`
|
||
queries snapserver's JSON-RPC control API (`Server.GetStatus` over raw TCP,
|
||
default port 1705 — the HTTP/snapweb port 1780 does not speak the
|
||
newline-delimited protocol; `control_host`/`control_port` fields) and
|
||
counts connected, unmuted clients — optionally only those in groups playing
|
||
`stream_name` — so idle shutdown tracks real listeners like the Icecast
|
||
status endpoint does. `update_metadata` is a no-op: snapserver's control
|
||
API offers no metadata setter — stream metadata can only originate from a
|
||
`controlscript` plugin attached to the source in `snapserver.conf`.
|
||
- **Driver interface** (`streams/driver.py`) — `start`, `stop`,
|
||
`set_source`, `pause`, `resume`, `seek`, `set_volume`, `update_metadata`,
|
||
`health`, `is_paused`, `listener_count`, and an `events` queue. The worker
|
||
uses `driver.is_paused` (not the persisted session state, which is already
|
||
post-command by the time the worker reads it) to detect pause→play
|
||
transitions.
|
||
|
||
---
|
||
|
||
## Bulk Downloads
|
||
|
||
Authenticated users can request ZIP archives of tracks — a selection made in
|
||
the library's bulk-edit mode, or a whole album, artist, playlist, or
|
||
library — which are built asynchronously by a Celery task and downloaded
|
||
from the `/downloads` page when ready.
|
||
|
||
**Request flow** (`api/routes/downloads.py`,
|
||
`services/downloads.py`):
|
||
|
||
1. `POST /api/v1/downloads/` accepts explicit references (`track_ids`,
|
||
`remote_object_ids`, `episode_ids`) and/or containers (`album_id`,
|
||
`artist_id`, `playlist_id`, `library_id`) plus an optional `label`.
|
||
`services.downloads.resolve_archive_items` expands containers through
|
||
the regular ACL — items the requester cannot access are silently
|
||
skipped — and caps the result at `downloads.max_items` (default 500).
|
||
A request that resolves to zero accessible items is rejected.
|
||
2. A `DownloadArchive` row (`models/download.py`) is created in `pending`
|
||
state with a JSON `items` snapshot (kind, id, title, artist, source
|
||
hints) so the build is reproducible even if the library changes.
|
||
`downloads.max_active_per_user` (default 2) limits how many
|
||
pending/processing archives one user may hold. Creation, file download,
|
||
deletion, and clear go through the shared Redis sliding-window rate
|
||
limiter; the read-only list/detail endpoints are unthrottled so the
|
||
page can poll freely.
|
||
3. `tasks/downloads.py`'s `build_download_archive` Celery task materializes
|
||
each item into a ZIP on local disk, stores it through `StorageService`
|
||
as a private `StoredFile`, and flips the row to `ready` (or `failed`
|
||
when no item succeeded). Per-item failures are collected into
|
||
`item_errors`, so a partially failed archive is still delivered.
|
||
Instance-wide concurrency is capped at
|
||
`downloads.max_concurrent_archives` (default 2) by a Redis sorted-set
|
||
semaphore (`songhive:downloads:archive-slots`) with expiring leases —
|
||
tasks that can't take a slot requeue themselves; the limiter fails open
|
||
when Redis is unavailable.
|
||
4. The requester gets a `download` notification (`archive_id` + `label`
|
||
payload) pointing at the Downloads page.
|
||
|
||
**Item materialization** covers every playable source:
|
||
|
||
- Local tracks stream from their `StoredFile` via the storage backend.
|
||
- External-library tracks go through the provider adapter's `download` /
|
||
`open_stream` (`services/streaming.py`'s external-stream helpers).
|
||
- Remote (federated) objects and podcast episode enclosures are fetched to
|
||
disk by `federation/fetch.py`'s `guarded_download` — the same SSRF
|
||
protection as `guarded_fetch` (DNS/IP checks, per-redirect-hop
|
||
revalidation, remote-domain moderation), streamed with a
|
||
`fetch_timeout_seconds` budget and a `max_item_bytes` cap (default 2 GiB).
|
||
- Transient remote/external fetches retry `fetch_attempts` times
|
||
(default 3) with `fetch_backoff_seconds` exponential backoff.
|
||
|
||
ZIP member names are sanitized (`01 - Artist - Title.mp3`,
|
||
collision-deduped) so archives unzip cleanly on any platform.
|
||
|
||
**Lifecycle** — `GET /api/v1/downloads/` lists the caller's archives
|
||
(newest first); `GET .../{archive_id}/file` streams a ready ZIP;
|
||
`DELETE .../{archive_id}` removes the row and its backing file;
|
||
`POST .../clear` removes all ready/failed rows. The hourly
|
||
`cleanup_completed_downloads` beat task deletes ready/failed archives
|
||
older than `downloads.retention_hours` (default 24) and marks
|
||
pending/processing rows stale for more than `downloads.stale_run_hours`
|
||
(default 6) as failed.
|
||
|
||
**Frontend** — `TrackList.vue`'s bulk-edit bar, `AlbumView`, `ArtistView`
|
||
and `PlaylistView` expose a Download action through the shared
|
||
`useDownloadArchive` composable (toast feedback + link to the Downloads
|
||
page). `DownloadsView` (`/downloads`, own nav entry) polls every five
|
||
seconds while archives are active and offers per-archive download/delete
|
||
plus a confirmed "clear completed" action. `api/downloads.ts` and the
|
||
regenerated `types.ts` carry the typed client.
|
||
|
||
---
|
||
|
||
## Payments
|
||
|
||
Optional payments support (`payments.enabled`, `[payments]` config) covers
|
||
two flows: **artist sales** (fans buy tracks or albums; sellers are paid
|
||
through their own Stripe Connect accounts) and **paid instance
|
||
memberships** (`registration_mode = "paid"` gates accounts behind an
|
||
instance subscription, default $5/month configurable via admin settings).
|
||
|
||
**Provider abstraction** (`services/payments/providers/`) — a small
|
||
`PaymentProvider` protocol with a Stripe implementation (Checkout Sessions
|
||
with direct charges on connected accounts for sales, Billing for
|
||
memberships, Connect for seller onboarding) and a deterministic `fake`
|
||
provider used by the test suite. Seller accounts are created through the
|
||
Accounts v2 API (`POST /v2/core/accounts`) — Stripe disables Accounts v1
|
||
creation for new Connect integrations — with `merchant.card_payments`
|
||
(direct charges) and `recipient.stripe_transfers` capabilities,
|
||
`identity.country` taken from the seller's onboarding request (required
|
||
before a `merchant` configuration can be applied). `payments.stripe_dashboard`
|
||
picks the hosted dashboard and pins the only `defaults.responsibilities`
|
||
combination Stripe accepts for it without approval: `full` (default) gives
|
||
sellers the standard Stripe dashboard with
|
||
`fees_collector`/`losses_collector` = `stripe`; `express` gives the
|
||
co-branded Express dashboard with platform-collected fees and
|
||
`losses_collector` from `payments.stripe_losses_collector` — `application`
|
||
requires managed-risk approval, `stripe` requires the gated Express +
|
||
Stripe-managed-liability preview (`account_controller_unsupported_configuration`
|
||
otherwise). Onboarding uses v2 Account Links and `connected_account_state`
|
||
normalizes both v1 (`account.updated` webhook) and v2 (live retrieve)
|
||
payload shapes into the stored `ConnectedAccount` flags. Provider events are the only authority that moves
|
||
money state: checkout redirects are never trusted.
|
||
|
||
**Data model** (`models/payments.py`) — `Sale` (per track or album, with
|
||
`unpaid_policy` of `full_stream`/`sample`/`none`), `PaymentOrder` +
|
||
`PaymentOrderItem` (both `purchase` and `membership` kinds; guest emails
|
||
are Fernet-encrypted), `PurchaseEntitlement` (permanent per-track rights
|
||
keyed on `user_id` or a guest `email_hash`), `RedeemCapability` (opaque,
|
||
expiring, rate-limited guest download tokens), `ConnectedAccount`,
|
||
`InstanceSubscription`, `PurchaseArtifact`/`SampleDerivative` (paid
|
||
snapshot ZIPs and trimmed samples as private `StoredFile`s), plus
|
||
`PaymentEvent` inbox and `FulfillmentOutbox` rows for idempotent webhook
|
||
processing and durable guest delivery.
|
||
|
||
**Access policy** (`services/payments/access.py`) is the single source of
|
||
truth for byte-level access: owners/sellers/admins and active entitlements
|
||
get `full`; unpaid listeners get the sale's policy level (`stream`,
|
||
`sample`, or `none`). Refunded or disputed orders revoke entitlements.
|
||
Every media path consults it — serialized track responses rewrite
|
||
`audio_url`/`can_download`, the Tornado stream handler serves original
|
||
bytes, the configured sample, or 403 by level (`SubsonicDownloadHandler`
|
||
flips `_requires_download_access` so `download.view` needs `full`), and
|
||
`files/{id}/download`, `tracks/{id}/download`, download-archive
|
||
materialization, share pages, feeds, M3U and federation serializers all
|
||
suppress or substitute URLs the requester isn't entitled to.
|
||
|
||
**Membership lifecycle** (`services/payments/membership.py`) —
|
||
`sync_user_active_flag` is the only writer of `User.is_active` for payment
|
||
reasons, combining `payments_required`, `admin_suspended`, subscription
|
||
`paid_through` + `membership_grace_hours`, and email verification. Paid
|
||
registrations start inactive; `invoice.paid` activates only when email is
|
||
verified (verification itself re-syncs). Unpaid-but-verified users get a
|
||
402 with a scoped Redis billing capability (`billing_capability_ttl_seconds`)
|
||
that authorizes only the membership endpoints. `POST
|
||
/payments/membership/session` exchanges a billing capability or a fresh
|
||
`order` checkout token for a real session once the webhook-confirmed state
|
||
shows the account active — each scoped credential mints exactly one session
|
||
(Redis single-use marker) and the order token only within the capability TTL
|
||
of the order's last update, the same freshness window membership portal and
|
||
cancellation enforce for order-token callers. Admin deactivation/deletion
|
||
cancels provider subscriptions first, with durable retry intent when the
|
||
provider call can't be confirmed.
|
||
|
||
**Webhooks** (`POST /api/v1/payments/webhooks/stripe{,-connect}`, CSRF-exempt
|
||
server-to-server calls) verify the provider signature, persist a normalized
|
||
`PaymentEvent` inbox row, and process idempotently through
|
||
`tasks/payments.py`'s `process_payment_event`. Beat-scheduled sweeps bound
|
||
lost deliveries: membership expirations, pending-order expiry, outbox
|
||
retries, and artifact cleanup.
|
||
|
||
**Frontend** — `PurchasePanel`/`SaleEditor`/`MembershipPanel` components,
|
||
`/checkout/*`, `/redeem`, `/billing` routes, profile Purchases/Billing tabs
|
||
(shown only when payments are enabled), login 402 → `/billing?bcap=…`
|
||
routing, and admin `payments_required` toggles on the Users page.
|
||
|
||
---
|
||
|
||
## Federation
|
||
|
||
Federation is powered by [pubby](https://github.com/blacklight/pubby) mounted
|
||
on the FastAPI app via its FastAPI adapter. Per-user actor routes and WebFinger
|
||
discovery are in `api/routes/federation.py`. The general-purpose primitives are
|
||
delegated to pubby:
|
||
|
||
- Outbound plaintext→HTML rendering (escaping, URL linkification,
|
||
`rel="tag"`/`rel="me"` anchors, `Hashtag` and `PropertyValue`
|
||
tag/attachment builders) and `Audio` object content/duration formatting
|
||
(`set_object_content`, `format_duration`) come from `pubby.content`; the
|
||
instance's `/tags/{name}` route convention is injected via
|
||
`federation/_common.py`'s `get_tag_url`.
|
||
- Instance allow/block matching (`normalize_domain`, `extract_domain`,
|
||
`is_domain_blocked`) comes from `pubby.moderation`; `services/federation.py`
|
||
keeps thin wrappers that inject `config.federation.allowed_instances` /
|
||
`blocked_instances`.
|
||
- Async→sync database URL conversion for pubby's synchronous SQLAlchemy
|
||
storage is `pubby.storage.adapters.db.to_sync_url` (applied by
|
||
`init_db_storage` inside `create_activitypub_storage`).
|
||
- Actor private-key provisioning is `pubby.crypto.ensure_private_key_file`.
|
||
- Follower inbox collection is `pubby.collect_inboxes`; one-shot signed
|
||
delivery is `pubby.deliver_activity` (the Celery task keeps the retry
|
||
policy).
|
||
|
||
Songhive keeps orchestration: Celery tasks and their retry policy, per-user
|
||
actor documents built from the `User` model, `federation_*` table naming, and
|
||
the HTTP routes.
|
||
|
||
**Actor model:**
|
||
|
||
- Each `User` has `actor_url`, `private_key_pem`, and `public_key_pem` columns.
|
||
- New users are provisioned with RSA keypairs on creation when federation is
|
||
enabled. Existing users can be back-filled via
|
||
`songhive admin provision-federation-keys`.
|
||
- Each user is reachable at:
|
||
- `https://{instance_domain}/users/{username}` — canonical `Person` actor
|
||
- `https://{instance_domain}/@{username}` — Mastodon-style alias
|
||
- `https://{instance_domain}/.well-known/webfinger?resource=acct:{username}@{instance_domain}`
|
||
|
||
**Activity lifecycle:**
|
||
|
||
- Uploaded tracks are published as `Audio` objects via `Create` activity —
|
||
only when explicitly requested. Uploads are local-only by default; the
|
||
`/api/v1/files/upload` endpoints (single and bulk) and the
|
||
`/{library_id}/tracks` upload endpoints take an opt-in `publish=true`
|
||
flag (surfaced in the UI as a "Publish on the Fediverse" checkbox shown
|
||
when the instance federates), which the synchronous library bulk path and
|
||
the background `process_upload` Celery task honour. Without the flag the
|
||
track receives no `federation_object_id` and no publication activity.
|
||
`process_upload` defaults the flag to `true` so directory scans keep
|
||
publishing imported public tracks.
|
||
- The published `Audio` object carries the track's `description` (a free-text
|
||
field settable at upload time or via `PATCH /tracks/{id}`) as its
|
||
`content`: the text is HTML-escaped, http(s) URLs become anchors with
|
||
scheme-less link text, and `#tags` become `rel="tag"` links to this
|
||
instance's `/tags/{name}` pages. Tags found in the description are
|
||
also appended to the object's `tag` list, and the media download URL is
|
||
attached as a `Document` with the audio MIME type. The rendered `content`
|
||
ends with a `<p><a href="{track_url}">{artist} - {title}</a></p>` link
|
||
back to the track page (`normalize_post_content`), and `summary` is left
|
||
unset: Akkoma and Mastodon both treat a non-empty `summary` as a content
|
||
warning, so mirroring the post body into it surfaced the raw HTML as a CW
|
||
header on Akkoma. The object's `name` is plain text
|
||
`{artist} - {title}` (HTML-escaped): Akkoma already wraps `name` in its
|
||
own anchor to the object `url`, so an HTML `name` produced nested
|
||
anchors. The object also carries a stable `published` timestamp (the
|
||
track's `created_at`) — Akkoma falls back to the Unix epoch when it is
|
||
missing — and the `Create` envelope is stamped with a unique `id` and its
|
||
own `published`. The `url` links
|
||
also mirror `mediaType`
|
||
into `mimeType` — Mastodon's link selection reads the non-standard
|
||
`mimeType` key and defaults untyped links to `text/html`, so without it
|
||
the raw audio download URL would be chosen for display instead of the
|
||
track page.
|
||
- Each public lifecycle gets a fresh `Track.federation_object_id` (generated
|
||
when a transition to public is published — album propagation or a track
|
||
`PATCH` with `publish=true` — and re-minted by every `audio`
|
||
republication) so a previous `Tombstone` at the same URL cannot block
|
||
re-publication.
|
||
- `POST /api/v1/tracks/{id}/publish` publishes a public track to the
|
||
fediverse at any time (the "Fediverse" tab of the share dialog). Any
|
||
authenticated user may share a public track as a `Create(Note)` post
|
||
under their own actor; republishing the canonical `Create(Audio)`
|
||
object (`object_type=audio`, which re-mints
|
||
`Track.federation_object_id`) is restricted to the track's owner and
|
||
admins. The publication activity is attributed to the publisher — its
|
||
`owner_user_id` — who can edit or retract it via the activity
|
||
endpoints even when they cannot manage the track itself. It accepts an
|
||
optional `status` — a one-off post text used
|
||
as the object's `content` instead of the stored
|
||
`description`, run through the `process_mentions` pipeline so `@handle`s
|
||
become links, `Mention` tags, and `activity_mentions` rows — an
|
||
optional `visibility` (default `public`) selecting the post's audience:
|
||
`public`/`followers`/`mentioned` federate to their respective audiences,
|
||
while `private`/`local` record the activity without delivering it, and an
|
||
optional `object_type` (default `note`). `note` shares the track as a
|
||
`Create(Note)` — the post body renders on every remote server (Mastodon
|
||
drops `content` on `Audio` objects, which it treats as a converted type)
|
||
— with the stream embedded as an `Audio`-typed attachment linked to the
|
||
track's canonical object when it has one; each share mints its own object
|
||
id, and the share's `url` is that object id rather than the track page:
|
||
the track URL dereferences to the canonical `Audio` object, so a remote
|
||
URL lookup (e.g. Mastodon's search box) resolves the `Audio`, while the
|
||
share keeps a distinct identity resolvable through its own object URL.
|
||
The track page still ends the share's `content` as the appended
|
||
`{artist} - {title}` link — `normalize_post_content` takes the page URL
|
||
explicitly (`link_href`) since it can no longer be read from the object's
|
||
`url`. `audio` republishes the canonical `Create(Audio)` media object.
|
||
The status is never persisted on the track. The `to`/`cc` addressing derived
|
||
from the visibility is applied to both the `Create` envelope and the
|
||
embedded object. Each `audio` publication mints a fresh
|
||
`federation_object_id` so every post is a distinct remote object.
|
||
- `Delete(Tombstone)` is sent when a track is made non-public or deleted.
|
||
- Tracks backed by metadata-only providers (providers declaring
|
||
`capabilities.limits["federate_audio"] = False`, currently `tidal` and
|
||
`youtube`)
|
||
federate without Songhive-hosted audio: `track_to_audio_object` and
|
||
`track_to_note_object` suppress the stream/download URL and emit a
|
||
provider browse link instead (e.g. a `Link` to
|
||
`tidal.com/browse/track/{id}` on the `Audio` object, or a `text/html`
|
||
`Document` attachment on `Note` shares), so remote servers never fetch
|
||
proxied provider bytes. The decision lives in
|
||
`federation/_common.py::provider_browse_link` and is taken at
|
||
serialization time so local metadata stays complete.
|
||
- Profile changes (display name, bio, avatar, links) refresh the cached actor
|
||
document via `sync_user_actor` and are pushed to follower inboxes as
|
||
`Update(Person)` activities.
|
||
- Profile links are emitted as `PropertyValue` attachments whose `value` is an
|
||
HTML anchor (`<a href="..." rel="me">`) so remote servers render them as
|
||
clickable fields. The URL is only linkified when it is a well-formed
|
||
http(s) URL with a host and no HTML-breaking characters; malformed values
|
||
are emitted as escaped plain text instead. Anchor text omits the `http(s)://`
|
||
scheme for readability.
|
||
- The actor `summary` is rendered from the bio as escaped HTML with http(s)
|
||
URLs linkified into anchors (scheme-less link text); sentence punctuation
|
||
wrapping a URL stays outside the anchor, and URL-looking text that fails
|
||
validation is left as escaped text.
|
||
- Following/unfollowing uses standard AP `Follow`/`Undo(Follow)` activities.
|
||
- `track.genre` is split into multiple `Hashtag` tags on the published
|
||
`Audio` object, with spaces converted to underscores. These tags include
|
||
an `href` pointing at the instance's `/tags/{name}` page.
|
||
- Per-actor follower isolation is delegated to pubby's `target_actor_id`.
|
||
- Incoming `Follow`/`Undo(Follow)` activities persist or remove rows in
|
||
pubby's `federation_followers` storage; pubby's `InboxProcessor` also
|
||
retracts the follower when a remote actor's `Delete` targets the
|
||
sending actor itself — scoped to the actor the processor is bound to.
|
||
For shared-inbox deliveries that binding is the instance actor, so
|
||
`process_incoming` extends the retraction: a verified self-`Delete`
|
||
removes all of the actor's follow records
|
||
(`_retract_shared_inbox_follows`). The sweep only runs after pubby's
|
||
signature verification bound the signer to `activity.actor`, and is
|
||
skipped for blocked/non-allowed domains, which the processor drops
|
||
before verifying. `services/federation.get_actor_followers` returns
|
||
an actor's followers newest-first by `followed_at` and
|
||
`count_followers_by_actor` provides per-actor counts, backing the
|
||
`followers_count` field on `GET /api/v1/users/{username}` and the
|
||
paginated `GET /api/v1/users/{username}/followers` JSON endpoint
|
||
(distinct from the ActivityPub `OrderedCollection` served at
|
||
`/users/{username}/followers`). The SPA shows the count on
|
||
`/@{username}` and renders follower details at `/@{username}/followers`.
|
||
- *Follower approval*: each user's `followers_approval` preference
|
||
(`accept`, `manual`, or `reject`; default `accept`, editable from
|
||
`/settings` via `PATCH /api/v1/users/me`) decides how incoming `Follow`
|
||
activities are handled. `process_incoming` passes pubby's
|
||
`InboxProcessor` a `follow_policy` callback that resolves the target
|
||
actor URL to the owning user's setting (object follows and the
|
||
instance actor keep the auto-accept default). `accept` stores the
|
||
follower and replies `Accept` as before; `manual` stores a pending
|
||
`FollowRequest` in pubby's `federation_follow_requests` storage, sends
|
||
no reply, and flags the resulting `follow` notification with
|
||
`follow_request_pending` so the UI can render accept/reject controls
|
||
in the notification body; `reject` answers with a `Reject` activity
|
||
and stores nothing (no notification either). Pending requests are
|
||
owner-only: `GET /api/v1/users/me/follow-requests` lists them and
|
||
`POST .../follow-requests/accept|reject` resolve them through
|
||
pubby's `accept_follow_request`/`reject_follow_request`, which embed
|
||
the original `Follow` in the response, promote approved requests to
|
||
`federation_followers` rows, and hand the reply to the
|
||
`deliver_activity` Celery task for signed delivery. Resolving a
|
||
request also rewrites the notification payload to
|
||
`follow_request_status` (`accepted`/`rejected`) and pushes a
|
||
`notification_updated` event. `Undo(Follow)` removes a still-pending
|
||
request. The SPA exposes requests as an owner-only "Requests" tab on
|
||
`/@{username}/followers`; the actor document advertises
|
||
`manuallyApprovesFollowers` (and the Mastodon-compatible account sets
|
||
`locked`) while the policy is `manual`.
|
||
- *Object follows*: a `Follow` may also target a local **object** rather
|
||
than an actor — Friendica sends `Follow` on a thread's root item
|
||
(`parent-uri`) for conversation subscriptions, the FEP-efda
|
||
"followable objects" pattern. Pubby stores those rows scoped to the
|
||
object's own URL in `target_actor_id`, so they never count as actor
|
||
followers (the counts above key on the actor URL) and `Undo(Follow)`
|
||
retracts them by the same key. `Follow`s targeting *remote* actors or
|
||
objects are dropped by the processor without an `Accept` — the remote
|
||
server owns their followers collection. Object-scoped rows are read in
|
||
bulk through `storage.get_followers_of_targets(...)` and their inboxes
|
||
collected by `services/federation.get_object_follower_inboxes`:
|
||
`resolve_audience` fans public activities out to followers of the
|
||
activity's own object id and every `in_reply_to_activity_id` ancestor
|
||
(covering replies to followed threads), and newly materialized public
|
||
remote replies/quotes are relayed to the same subscribers — the raw
|
||
activity forwarded verbatim and signed by the nearest local ancestor's
|
||
owner, or the instance actor when the chain has no local node
|
||
(`federation/incoming._relay_thread_activity`). Served object documents
|
||
advertise their `followers` collection (an `OrderedCollection` of the
|
||
subscribers' actor ids) at
|
||
`GET /users/{username}/objects/{object_id}/followers`. An object-scoped
|
||
`Follow` creates a `follow` notification only for the object's owner —
|
||
a personal-inbox delivery addressed to another user is misaddressed and
|
||
skipped — with `target_*` payload fields describing the followed
|
||
object, matching the reply/quote payload convention.
|
||
|
||
**Outbound follows** — a local user following a local or remote actor —
|
||
are owned by `services/follows.py` and persisted in the `follows` table
|
||
(`models/follow.py`), which is the mirroring *outbound* side of pubby's
|
||
inbound follower/request tables. `POST /api/v1/users/me/follows` accepts a
|
||
bare local username, a `@user@domain` handle, or an actor/profile URL
|
||
(`remote_content.parse_remote_target` splits local from remote inputs,
|
||
`lookup_remote_actor` resolves remote actors through the guarded fetch
|
||
and actor cache). Local targets apply the owner's `followers_approval`
|
||
policy immediately — `accept` writes the pubby follower row and stores an
|
||
`accepted` follow, `manual` writes a pending `FollowRequest` and keeps
|
||
the row `pending`, `reject` fails with 403 — and the target gets the
|
||
usual `follow` notification. Remote targets store a `pending` row whose
|
||
`activity_id` matches the `Follow` activity's own id, enqueue it signed
|
||
on `deliver_activity`, and stay pending until the remote server answers:
|
||
`process_incoming` folds inbound `Accept`/`Reject` activities back into
|
||
the row via `apply_follow_decision` (matched on the deciding actor being
|
||
the follow target plus the wrapped activity id or follower actor URL;
|
||
`Accept` marks the row `accepted`, `Reject` drops it). `DELETE
|
||
/api/v1/users/me/follows` reverses the relationship: local targets drop
|
||
the pubby follower/request rows and retract the notification, remote
|
||
targets get a signed `Undo(Follow)` embedding the original activity
|
||
(rebuilt from the stored `activity_id`) delivered to the recorded inbox.
|
||
When a local owner resolves a pending request, the accept/reject
|
||
endpoint also updates the local requester's row directly through
|
||
`apply_local_follow_decision` — the federated reply only reaches remote
|
||
requesters. `GET /api/v1/users/me/follows` lists the caller's follows in
|
||
all states; `GET /api/v1/users/{username}/follows` lists `accepted`
|
||
follows publicly and includes `pending` rows for the owner. Profiles
|
||
expose `follows_count` plus the viewer-relative `follow_state`, and the
|
||
remote-actor endpoint reports `follow_state` for authenticated callers.
|
||
The SPA renders the list at `/@{username}/follows` (pending badges for
|
||
the owner) and follow/unfollow buttons on local and remote profiles.
|
||
|
||
**Inbound remote replies, quotes and announces** are materialized into
|
||
`Activity` rows by `federation/incoming.py`, invoked from
|
||
`tasks/federation.py`'s `process_incoming` for
|
||
`Create`/`Update`/`Delete`/`Accept`/`Reject`/`Announce`/`Undo`
|
||
activities after pubby's `InboxProcessor` has run. Admission is gated on
|
||
the outbound-follow graph: a `Create` is only stored when its
|
||
`activity.actor` is followed by at least one local user
|
||
(`follows_service.actor_is_followed`) — objects fetched explicitly
|
||
through remote URL lookup arrive through the dereference path instead,
|
||
so nothing from unfollowed actors accumulates locally. `Update`s to
|
||
already-stored objects always apply (the row was admitted by the Create
|
||
gate or explicit lookup); an `Update` for an unknown object — replaying
|
||
a missed `Create` — is subject to the same followed-actor rule, and
|
||
`Accept`/`Reject` activities additionally answer outbound `Follow`s and
|
||
`QuoteRequest`s. A `Create` whose object replies
|
||
to — or quotes — a known activity (matched by `source_id` or the
|
||
`/objects/{id}` permalink form) becomes a `source_type="remote"`
|
||
`reply`/`quote` row attached to the parent's entity, linked through
|
||
`in_reply_to_activity_id`, carrying the raw activity as `payload` and the
|
||
remote actor as `source_actor`. Quote fields (FEP-0449 `quote`, Mastodon
|
||
`quoteUrl`, Misskey `_misskey_quote`) take precedence over `inReplyTo`,
|
||
mirroring pubby's interaction typing. Publicly
|
||
addressed objects keep the entity-clamped `public` visibility; non-public
|
||
ones (direct messages, followers-only) are stored with `mentioned`
|
||
visibility when they address at least one local user — through
|
||
`to`/`cc`/`bto`/`bcc` addressees or `Mention` tags — each resolved local
|
||
addressee getting an `ActivityMention` row so only that audience sees the
|
||
post in the listing. Non-public objects addressing no local user are not
|
||
materialized. A `Create` whose object targets no known activity — a
|
||
standalone post, or a reply/quote whose parent was never cached — is
|
||
instead stored through `remote_content.materialize_remote_post`: the
|
||
object is upserted into `remote_objects` (same shape as explicit URL
|
||
lookup, minus the fetch) and mirrored as an `entity_type="remote"`
|
||
`Activity`, so a followed actor's outbox shows up on their remote
|
||
profile's posts tab. Non-public standalone posts keep `private` cache
|
||
visibility and `local` activity visibility. `Update` revises
|
||
content, payload, mentions, hashtags and visibility (materializing
|
||
posts whose `Create` was missed, degrading rows that lose their
|
||
public audience to `mentioned`, and refreshing the mirrored
|
||
`remote_objects` row for standalone posts); `Delete` soft-deletes the
|
||
row and tombstones the cache row's `unavailable_at`, but only for the
|
||
recorded `source_actor`. Attribution is enforced by pubby's `attribution.validate`:
|
||
`process_incoming` enables `strict_attribution` on the `InboxProcessor`,
|
||
and materialization/update re-apply `pubby.validate_attribution` to the
|
||
raw activity since the sync runs regardless of the processor's verdict —
|
||
the delivering actor must match `attributedTo` and share the object's
|
||
host. An `Accept` answering a `QuoteRequest`
|
||
we sent (`apply_quote_authorization`) stamps the issued
|
||
`QuoteAuthorization` id onto the local `quote` row's stored `Create`
|
||
payload — and fans out an `Update` — but only when the accepting actor is
|
||
the quoted post's author and the `instrument`/`object` match the recorded
|
||
quote and target. Because the Pubby `federation_interactions` record
|
||
is kept alongside the row, reply/quote counts and listings deduplicate on
|
||
`object_id`; boosts (stored `announce` rows vs. recorded `BOOST`
|
||
interactions) deduplicate on `activity_id` — the announce's own id —
|
||
since the interaction record has no separate object id. Materialized
|
||
replies and quotes render as regular activity
|
||
cards and
|
||
accept the same interactions as local ones: likes and boosts federate
|
||
to the remote author's inbox through `fan_out_like_activity`/
|
||
`fan_out_boost_activity`, and local replies/quotes address the remote
|
||
author via
|
||
a `Mention` tag with `inReplyTo`/quote fields set to the remote
|
||
`source_id`.
|
||
|
||
An inbound `Announce` from a followed actor
|
||
(`materialize_remote_announce`) is stored as a `source_type="remote"`
|
||
`announce` row attached to the boosted activity's entity through
|
||
`in_reply_to_activity_id` — the same shape `boost_activity` produces
|
||
locally — so the boost surfaces in timelines as an "X boosted" card. A
|
||
boosted object unknown locally is first fetched through
|
||
`remote_content.dereference_remote_object` — the guarded remote fetch
|
||
that upserts the `remote_objects` cache row, materializes the object's
|
||
mirror `Activity`, and caches its author actor — and the boosting actor
|
||
is refreshed through `lookup_remote_actor` so the card renders a
|
||
profile. Publicly addressed announces inherit the target's visibility;
|
||
non-public ones are stored with `mentioned` visibility only when they
|
||
address a local user. `Undo(Announce)` — whether it wraps the full
|
||
announce or only its id — soft-deletes the row, and `Delete` of an
|
||
announce retracts it without tombstoning the boosted object's cache row
|
||
(only rows mirroring the deleted object itself tombstone their
|
||
`remote_objects` row). Likes stay interaction-only.
|
||
|
||
**Instance-level actor:**
|
||
|
||
- `_setup_federation()` in `api/app.py` configures an `Application`-type
|
||
actor via pubby's `ActorConfig` and mounts both ActivityPub and Mastodon API
|
||
compatibility endpoints.
|
||
|
||
**Domain allow/block lists** (`federation.allowed_instances` /
|
||
`federation.blocked_instances`) are enforced at several seams:
|
||
|
||
- `POST /users/{username}/inbox` and `POST /ap/inbox` in
|
||
`api/routes/federation.py` reject with 403 before queueing
|
||
(`is_domain_allowed`). The shared-inbox route is Songhive's own — it
|
||
shadows pubby's synchronous handler so deliveries queue onto
|
||
`process_incoming` (with `username=None`) like per-user inboxes: reply
|
||
materialization and notifications apply there too, whereas pubby's
|
||
handler stopped at interaction storage and dropped non-public `Create`s
|
||
entirely. For shared-inbox deliveries the notification recipients are
|
||
resolved from the activity's audience
|
||
(`federation/notifications.resolve_inbox_recipients`): local actor URLs
|
||
in `to`/`cc`/`bto`/`bcc`, `Mention` tag targets, string `object`
|
||
targets, and the local owners of objects referenced through
|
||
`inReplyTo`, quote fields, or Like/Announce targets.
|
||
- `tasks/federation.py`'s `process_incoming` passes the lists to pubby's
|
||
`InboxProcessor`, which drops the activity before signature verification.
|
||
- Pubby's outbound fan-out gets the same filtering via
|
||
`allowed_instances`/`blocked_instances` on `ActivityPubHandler`
|
||
(`_setup_federation` in `api/app.py`).
|
||
- `tasks/federation.py`'s `deliver_activity` drops outbound deliveries to
|
||
blocked domains before signing.
|
||
|
||
**Remote discovery (explicit lookup):**
|
||
|
||
Songhive supports explicit, bounded lookup of remote actors, activities, and
|
||
resources — without crawling remote timelines or indexing the fediverse.
|
||
|
||
- `services/remote_content.py` classifies lookup input (`@user@domain`
|
||
handles, actor/profile URLs, object/activity URLs, Songhive-style
|
||
`/tracks/{id}` resource URLs) and enforces the `remote_search_access`
|
||
policy (`disabled`/`authenticated`/`public`, default `authenticated`,
|
||
editable at runtime by admins) plus the federation domain allow/block
|
||
lists before any network access. Local-domain inputs never hit the
|
||
network — and skip the policy, since no remote access happens:
|
||
`resolve_local_target` maps them straight to their SPA route
|
||
(`/{kind}/{id}`, `/@{user}`, `/activities/{id}`; object permalinks
|
||
resolve through `Track.federation_object_id` /
|
||
`Activity.local_object_id`).
|
||
- All remote dereferencing goes through `federation/fetch.py`'s
|
||
`guarded_fetch`: only `http(s)` URLs, DNS answers restricted to globally
|
||
routable addresses, every redirect target revalidated (max 3 hops),
|
||
bodies capped at 1 MiB, ActivityPub content types required, 404/410
|
||
mapped to "gone". Each outbound request hop is bounded by
|
||
`federation.fetch_timeout_seconds` (default 20s, 1–300;
|
||
`SONGHIVE_FEDERATION__FETCH_TIMEOUT_SECONDS`, also admin-editable at
|
||
runtime). When federation keys exist, each hop is signed with the
|
||
instance actor key (HTTP signatures are regenerated per hop).
|
||
- Remote actors resolve through WebFinger and are cached in pubby's
|
||
`federation_actor_cache` — a fetch-on-miss cache with freshness checks
|
||
and tombstone markers for gone actors.
|
||
- Remote actor ids are not assumed to carry a username in their path tail:
|
||
newer Mastodon versions issue opaque `…/ap/users/{id}` URIs. Displayed
|
||
and routed handles therefore come from the cached actor document's
|
||
`preferredUsername` — `services/federation.actor_doc_handle` builds the
|
||
`user@domain` form, and `cached_actor_docs`/`cached_actor_handles`
|
||
batch-resolve it across the actor cache, follower and follow-request
|
||
tables without any fetch. API responses expose the resolved handle
|
||
(`handle`, `actor_handle`, `source_actor_handle`) so the SPA never has
|
||
to parse the actor URL; handle lookup (`_find_cached_actor_by_handle`)
|
||
scans the same tables on `preferredUsername` and falls back to matching
|
||
the actor URL's path tail, so both real handles and legacy numeric-id
|
||
links resolve to a cached actor before WebFinger is tried.
|
||
- Remote objects are cached in the `remote_objects` table (canonical URL,
|
||
activity URL, domain, type, normalized resource kind, denormalized
|
||
name/summary/content/media URLs, ETag/Last-Modified, content hash,
|
||
`unavailable_at` tombstone). `Create`/`Announce`/`Update`/`Like`
|
||
wrappers are unwrapped, `attributedTo` is validated against the
|
||
publishing actor, and `Delete`/`Tombstone` documents mark the cached
|
||
row unavailable instead of deleting it.
|
||
- Content objects materialize as `Activity` rows with
|
||
`entity_type="remote"` pointing at their `RemoteObject` row and
|
||
`source_type="remote"`/`source_id=<canonical URL>`; bare resource
|
||
documents (e.g. a dereferenced `Audio` track) are cached but never
|
||
become feed entries. Reply/quote parents resolve only against
|
||
already-cached rows — remote threads are never fetched recursively.
|
||
- Routes: `GET /api/v1/remote/lookup` (explicit lookup → internal URL),
|
||
`GET /api/v1/remote/actors/{handle}` (+ `/activities` for cached
|
||
posts), `GET /api/v1/remote/objects/{id}` (`refresh` re-dereferences
|
||
the canonical URL), and `GET /api/v1/remote/{kind}/{id}` for cached
|
||
remote resources. Internal SPA URLs are `/@user@domain`,
|
||
`/activities/@user@domain/{remote_object_id}`, and
|
||
`/remote/{kind}/{id}` — remote URLs are never used as navigation
|
||
targets.
|
||
- Aggregate search gains a cached-only `remote` section (`entities=remote`
|
||
or the `include_remote` flag) that never performs network access, plus
|
||
a `remote_available` flag telling the UI whether the caller may run an
|
||
explicit lookup.
|
||
|
||
**Federated music entities (Funkwhale dialect):**
|
||
|
||
Songhive publishes and consumes a Funkwhale-compatible ActivityPub music
|
||
dialect, so music entities federate bidirectionally with Funkwhale
|
||
instances and with other Songhive instances.
|
||
|
||
- `federation/serializers.py` serializes `Artist`, `Album`, `Track`,
|
||
`Audio` and `Library` documents under the `https://funkwhale.audio/ns`
|
||
context (`MUSIC_ENTITY_CONTEXT`): artists and albums carry
|
||
`musicbrainzId`/`released`/embedded `artists`, tracks carry
|
||
`position`/`disc` and a nested `album`, and `Audio` uploads carry an
|
||
integer `duration` plus `bitrate`/`size`, a `library` reference and the
|
||
embedded `track` document — the fields Funkwhale's serializers require
|
||
on import. `create_audio_activity` passes the publishing track's
|
||
library URL (`/libraries/{id}` for a public `Library`, else the owner's
|
||
implicit `{actor}/library` collection) so remote library followers
|
||
receive `Create(Audio)` deliveries.
|
||
- Dereferenceable routes in `api/routes/federation.py` serve the music
|
||
entities as ActivityPub JSON (HTML requests still get the SPA):
|
||
`/artists/{id}`, `/albums/{id}` (public albums only), `/libraries/{id}`
|
||
for public libraries — the collection index, with `?page=N` serving a
|
||
`CollectionPage` of `Audio` items and `/followers` serving the object's
|
||
follower collection — and `/users/{username}/library`, an implicit
|
||
followable library of the user's public tracks. Actor documents
|
||
advertise `endpoints.sharedInbox` and a `library` link to that implicit
|
||
collection.
|
||
- `remote_content.py` recognizes Songhive resource URLs and Funkwhale's
|
||
`/federation/music/{tracks,albums,artists,libraries,uploads}/{id}` and
|
||
`/federation/actors/{name}` paths. Music documents classify to a
|
||
normalized `resource_type` (`Audio`→track, `Track`→track,
|
||
`Album`→album, `Artist`→artist, `Library`→library); `uploads` URLs
|
||
normalize to `track`. Containment is recorded on the new
|
||
`remote_objects.parent_url` column (`Audio`→library, `Track`→album,
|
||
`Album`→first artist, collection pages→`partOf`), and entities embedded
|
||
in a fetched document (`Audio.track`, `Track.album`, `Album.artists`,
|
||
page `items`) are cached in the same table — bounded at 120 per fetch —
|
||
so remote album/library pages render their contents without a fetch
|
||
per child. `Library` documents additionally trigger one bounded fetch
|
||
of their first collection page; a pasted `?page=N` URL resolves to its
|
||
`partOf` collection. Funkwhale's `audience`-only public addressing is
|
||
honored (`_doc_is_public`), and actor URLs that fail to dereference
|
||
retry once through WebFinger.
|
||
- Remote resources can be **followed** object-scoped (FEP-efda style):
|
||
`POST/DELETE /api/v1/remote/objects/{id}/follow` stores a `follows`
|
||
row keyed on the object URL and delivers `Follow`/`Undo(Follow)` to the
|
||
object's controlling actor inbox (`actor`/`attributedTo`, e.g. a
|
||
Funkwhale library's channel actor). Inbound `Accept`/`Reject` are
|
||
matched in `apply_follow_decision` against the followed object *or* its
|
||
owning actor. Inbound `Follow`s of a local object delivered to the
|
||
shared inbox are processed under the object's owning user actor
|
||
(`_object_follow_owner_username` in `tasks/federation.py`) so the
|
||
`Accept.actor` matches the library's controlling actor — which Funkwhale
|
||
requires. Inbound `Create`/`Announce`/`Update` admission accepts
|
||
activities whose object is — or names as its container (`library`,
|
||
`context`, `target`) — a followed remote resource, in addition to the
|
||
followed-actor rule.
|
||
- Remote resources can be **collected**: `item_type="remote"` on
|
||
`POST/DELETE /api/v1/collection/{item_type}/{item_id}` bookmarks a
|
||
cached `remote_objects` row by id — only rows with a `resource_type`
|
||
are collectable, and nothing is copied into local music tables.
|
||
`GET /api/v1/remote/objects?collection=true&resource_type=...` lists
|
||
them (authenticated), and `RemoteCollectionSection.vue` renders them
|
||
inside the Tracks/Albums/Artists/Libraries/Playlists collection views.
|
||
- Remote media **resolution** happens at play time, never at cache time:
|
||
a remote *track* is metadata whose media can be a direct `audio/*`
|
||
link (`audio_url`) or a *rendition* sibling (`Audio`/`Video` document
|
||
embedding `track`). Rendition rows record that link on the indexed
|
||
`remote_objects.media_of_url` column (`Audio`→`track.id`), so
|
||
`media_of_url == track.canonical_url` resolves without payload scans
|
||
(a bounded JSON-path fallback covers rows cached before the column
|
||
existed). `GET /api/v1/remote/objects/{id}/stream` resolves through
|
||
`remote_content.resolve_media_url` — own `audio_url` first, else a
|
||
cached rendition — and 302s to the media URL (404 when nothing is
|
||
playable). Responses advertise `stream_url` only when something is
|
||
resolvable now; it is computed in one batched `rendition_audio_map`
|
||
query per response. This endpoint is the seam for providers whose
|
||
links expire or require resolution (e.g. future YouTube/Spotify
|
||
adapters): they plug into `resolve_media_url` and clients never
|
||
handle a remote URL directly.
|
||
- Remote object responses (`RemoteObjectResponse`) carry
|
||
`in_collection`, `follow_state`, `parent` and `items` (cached
|
||
children), and `RemoteResourceView.vue` renders them with follow and
|
||
collection `EntityActions`, a parent link and a contents list.
|
||
|
||
**Dereference stampede defenses** (`federation/doc_cache.py`, built on
|
||
`pubby.cache`) — a boosted post makes hundreds of remote instances
|
||
dereference the same actor, object, collection and WebFinger URLs within
|
||
seconds. Every AP read endpoint (`/users/{u}` actor documents and `@{u}`,
|
||
`objects/{id}` and their `followers`, `quote_authorizations`,
|
||
`/activities/{id}`, `/tracks/{id}`, outbox/followers/following
|
||
collections, nodeinfo, WebFinger and the music entities) serves through
|
||
`doc_cache.get_or_render`, which delegates to a shared
|
||
`pubby.cache.DocumentCache`: a bounded (10k-entry LRU) TTL cache
|
||
(`federation.document_cache_ttl_seconds`, default 60 s — misses are
|
||
capped at 15 s) with single-flight coalescing, so concurrent identical
|
||
fetches share one render task (a disconnecting waiter cannot cancel it
|
||
for the others), plus stale-if-error serving while an entry is within
|
||
`ttl * stale_factor`. Keys are tuples — `("obj", user, object_id)` —
|
||
so prefix/segment invalidation is element-wise and cannot collide across
|
||
users or endpoints. Renders open their own `get_session()` session — they
|
||
may outlive the request that started them, so they must not borrow the
|
||
request-scoped session. Invalidation is generation-guarded: a render in
|
||
flight when an invalidation lands cannot re-store a stale result.
|
||
Mutating paths call `invalidate_actor`, `invalidate_activity`,
|
||
`invalidate_track`, `invalidate_keys`, `invalidate_segment` or
|
||
`invalidate_prefix` (profile sync, retractions, cascade deletes,
|
||
follow-request approval, local follow/unfollow) with `session=` so the
|
||
drop fires on `after_commit` — an early invalidation would let a
|
||
concurrent render re-cache the pre-commit row; the listener ignores
|
||
SAVEPOINT releases (`after_commit` also fires for nested-transaction
|
||
commits) and drains only after the outermost commit. Track mutations
|
||
invalidate on the mutation itself, not via publication rows: a
|
||
public → private PATCH drops the track's cached page/object/library
|
||
documents even when no live `create` activities exist to retract, and
|
||
`sync_track_publications` drops the object/activity documents whose
|
||
stored payloads it rebuilds. Album and library PATCH routes and the
|
||
album/artist/library deletion paths drop their own documents the same
|
||
way (`("album", id)`, `("artist", id)`, `("lib", id, …)` plus the
|
||
visibility-changed tracks). Rare privacy-relevant events that touch
|
||
non-user-keyed documents —
|
||
admin suspension/unsuspension, payment deactivation, account deletion —
|
||
call `clear(session=…)` instead: `("trackpage", id)`, `("act", id)` and
|
||
the `lib`/`artist`/`album` keys are not scoped by username, so
|
||
`/tracks/{id}` and `/activities/{id}` would otherwise keep serving the
|
||
user's documents for up to the TTL. The TTL bounds whatever cross-process
|
||
staleness remains (best-effort). Responses carry `Cache-Control:
|
||
public, max-age=<remaining ttl>` — the cache entry's remaining
|
||
freshness, not a restarted full TTL, so downstream caches cannot extend
|
||
an aged document's lifetime; an exhausted-freshness response carries
|
||
`max-age=0, must-revalidate` and a disabled cache (`ttl = 0`) carries
|
||
`no-store` — plus `Vary: Accept` and an `ETag`
|
||
(conditional requests answer 304), so shared caches and the nginx edge
|
||
cache can absorb repeats before they reach the app. The nginx layer
|
||
(`docker/nginx.conf`) `proxy_cache`s only anonymous AP-JSON requests —
|
||
HTML variants and credential-bearing requests bypass it — mirrors the
|
||
app's Accept negotiation into three key classes (`*/*` counts as
|
||
HTML-capable, matching `_accepts_html`), ignores upstream `Vary` (the
|
||
normalized class is already in the key; honoring it would fragment on
|
||
every remote's raw `Accept` string), and single-flights misses with
|
||
`proxy_cache_lock`; the optional per-IP `limit_req` (429) requires
|
||
`set_real_ip_from` for the front proxy first and never applies to inbox
|
||
POSTs. The edge cache sits outside every invalidation hook — app-side
|
||
clears do not reach it — so every cacheable response also carries
|
||
`X-Accel-Expires` bounded by both the remaining in-process freshness and
|
||
`federation.edge_cache_ttl_seconds` (default 15 s, matching the
|
||
`proxy_cache_valid` fallback): the edge TTL can never outlive the app's
|
||
freshness policy, and stale serving is disabled at the edge so the
|
||
staleness window after a suspension or deletion is hard-bounded at that
|
||
cap. Underneath the cache, `init_db(url)` builds a
|
||
bounded `AsyncAdaptedQueuePool` engine **per event loop** (asyncpg
|
||
connections are loop-bound; the process runs several) honoring
|
||
`database.pool_size` / `max_overflow` / `pool_timeout` / `pool_recycle` /
|
||
`pool_pre_ping` — pool-timeout waits and Postgres-side
|
||
`too_many_connections` both fail fast with `503` + `Retry-After` instead
|
||
of hanging. Short-lived Celery/CLI task loops cap their async pool at
|
||
1+2 via `database_task_engine_kwargs`. The same `DocumentCache` instance
|
||
is passed to `bind_activitypub`/`ActivityPubHandler`, so pubby's own
|
||
adapter routes (`/ap/*`, instance actor, nodeinfo) share it and its
|
||
mutation hooks invalidate it — pubby's route keys live under a
|
||
`("pubby", …)` namespace so neither side's invalidations touch the
|
||
other's entries (Songhive's WebFinger route builds its own
|
||
`("webfinger", …)` key from pubby's `normalize_webfinger_resource`
|
||
rather than pubby's namespaced `webfinger_key`).
|
||
`federation/storage.py`'s `get_federation_storage`
|
||
delegates to pubby's memoized `get_db_storage` — the same sync engine is
|
||
reused process-wide instead of building a new engine and running
|
||
`create_all` per call; its pool is fixed at `pool_size=2,
|
||
max_overflow=3` (pubby calls are short), which also bounds each Celery
|
||
prefork child.
|
||
|
||
---
|
||
|
||
## Content Moderation
|
||
|
||
- `Report` model stores user-submitted content flags (target type/id, reason,
|
||
description, status, reviewer). Actor reports (`target_type` `user`/`actor`)
|
||
accept a username, `@user@domain` handle or actor URL, are normalized to
|
||
`target_type="user"`, and store the reported account's canonical actor URL in
|
||
`target_actor_url` (`target_id` holds the local user id or the remote actor
|
||
URL; legacy callers may still pass a bare user id). The `forwarded` flag
|
||
records whether the report was relayed to a remote instance.
|
||
- Public submission: `POST /api/v1/reports`. `forward: true` on a remote actor
|
||
report enqueues an ActivityPub `Flag` activity to the reported actor's inbox
|
||
via `tasks.federation.deliver_activity` (requires the reporter's federation
|
||
keys; otherwise the report is stored with `forwarded=false`).
|
||
- Every report notifies all active admins (except the reporter) through a
|
||
`report` notification carrying `report_id`/`target_type`/`target_actor_url`/
|
||
`reason`; the frontend links it to `/admin/reports`.
|
||
- Admin review: `GET/PUT /api/v1/admin/reports`. Responses include
|
||
`reporter_username`, `target_actor_url` and `forwarded`.
|
||
- `AuditLog` records administrative and security-relevant actions (actor, target
|
||
type/id, IP address, JSON details). `target_type` values are defined by the
|
||
`AuditTargetType` enum (`models/audit_log.py`) — `log_action` only accepts
|
||
enum members, and `GET /api/v1/admin/audit/target-types` exposes them to the
|
||
admin UI's target-type filter.
|
||
|
||
### Actor and instance moderation
|
||
|
||
Mastodon-style moderation lives in three tables (`models/moderation.py`) and
|
||
one service (`services/moderation.py`):
|
||
|
||
- `UserModeration` — per-user `mute`/`block` on any actor (local or remote),
|
||
keyed on the canonical actor URL (`user.actor_url`, or the
|
||
`urn:songhive:user:<name>` fallback when federation is disabled). A mute is
|
||
one-way: the target's activities leave the muter's feeds but the muter's
|
||
activities still reach the target. A block is reciprocal: it hides both
|
||
directions, prevents interaction both ways, and severs every follow
|
||
relationship between the pair (`sever_block_relationship` removes the
|
||
`follows` rows and the pubby follower/request records).
|
||
- `AdminUserModeration` — instance-wide `limit`/`suspend` on an actor with an
|
||
optional `reason`, unique per actor URL (applying a new action upgrades the
|
||
existing row). `limit` forces the actor's follows of local users through
|
||
manual approval (`follow_user` passes `force_manual`) and gates their
|
||
activities to their own followers — on timelines and on their profile
|
||
page, which shows a "limited by the moderators" notice and a "show
|
||
anyway" button that re-fetches the timeline with `reveal=1` (the
|
||
`reveal` query param lifts only the followers-only gate for that actor;
|
||
hidden/defederated content stays excluded). `suspend` additionally blocks all
|
||
interaction (`assert_interaction_allowed`), drops inbound activities
|
||
(`tasks/federation.process_incoming`), removes the actor from notification
|
||
fan-out, severs every local follow relationship (`sever_relationships`),
|
||
and hides all of the actor's content.
|
||
- `InstanceModeration` — per-domain `defederate`/`followers_only` policies
|
||
with an optional `reason`, layered over the configured
|
||
`federation.allowed_instances`/`blocked_instances` lists. Because the
|
||
synchronous federation paths (Celery delivery, pubby inbox resolution,
|
||
remote-actor search) cannot query the database, `load_instance_policies`
|
||
installs a process-local snapshot (`federation.set_db_instance_policies`)
|
||
that `is_domain_blocked`/`db_domain_policy` consult — a `defederate` row
|
||
behaves exactly like a configured block, while `followers_only` gates the
|
||
domain's activities to local users who follow the author.
|
||
|
||
Visibility is enforced through `moderation_context` (one small set of queries
|
||
per request) + `moderation_filter`/`activity_hidden`, applied to activity
|
||
lists, single-activity views, timelines, search results, and remote-actor
|
||
lookups. Interaction endpoints run `assert_not_suspended`/
|
||
`assert_interaction_allowed`; notification fan-out checks
|
||
`notification_suppressed`, which also drops filterable notifications
|
||
(everything but `report`) from limited actors and followers-only-domain
|
||
actors when the recipient does not follow them — Mastodon's
|
||
`for_limited_accounts: drop` policy. Federated dereference endpoints return 404 for
|
||
suspended owners, and defederated domains are dropped inbound (inbox
|
||
processing, remote materialization) and outbound (delivery domain checks,
|
||
audience resolution).
|
||
|
||
API surface:
|
||
|
||
- `GET/POST/DELETE /api/v1/users/me/mutes` and `.../blocks` — personal
|
||
moderation lists (`ModeratedActorResponse` carries a display snapshot).
|
||
- `GET/POST/DELETE /api/v1/admin/moderation/users` — admin limit/suspend.
|
||
- `GET/POST/DELETE /api/v1/admin/moderation/instances` — admin domain policies.
|
||
- `GET /api/v1/instance/domain_blocks` — Mastodon-compatible transparency
|
||
endpoint mapping `defederate`→`suspend` and `followers_only`→`silence`.
|
||
- `GET /api/v1/users/{username}` and `/api/v1/remote/actors/{handle}` report
|
||
`muted`/`blocked` (viewer-relative) and `limited`/`suspended` (instance)
|
||
flags; profile pages show the badges plus mute/block actions for signed-in
|
||
viewers and limit/suspend/clear actions for admins.
|
||
- `/settings?tab=moderation` lists the viewer's mutes/blocks with undo;
|
||
`/admin/moderation` manages all admin actions with optional reasons.
|
||
- All admin mutations record `moderation.<verb>` `AuditLog` entries.
|
||
|
||
---
|
||
|
||
## Sharing
|
||
|
||
Two independent mechanisms for sharing private content:
|
||
|
||
| Mechanism | Model | Route prefix | Description |
|
||
|---------------|---------------|----------------------|-----------------------------------------|
|
||
| Share grant | `ShareGrant` | `/api/v1/shares` | Owner grants a named user access |
|
||
| Share URL | `ShareToken` | `/api/v1/share-urls` | Revocable short link; token hash stored |
|
||
| URL resolver | — | `/api/v1/share/{token}` | Resolves raw token → HTML, JSON, or direct audio download |
|
||
|
||
The URL resolver is content-negotiated:
|
||
|
||
- Browsers and crawlers receive a rendered HTML preview page with OpenGraph
|
||
metadata, an audio player for tracks, and track listings for albums,
|
||
playlists, libraries, and artists.
|
||
- API clients that send `Accept: application/json` (including the web UI's
|
||
share preview) still receive a `302` redirect to the item's public JSON
|
||
endpoint, with a short-lived `share_token` cookie.
|
||
- Audio file shares redirect directly to the file download URL, and
|
||
`?download=true` forces a direct audio download for tracks or files.
|
||
|
||
The ACL service (`services/acl.py`) checks grants and tokens transparently
|
||
via the `require_access` FastAPI dependency used by resource routes.
|
||
|
||
### Embeddables
|
||
|
||
Public entities can also be embedded into third-party pages from the share
|
||
dialog's "Embed" tab (`components/share/EmbedPanel.vue`). Four snippet
|
||
formats are generated client-side (`utils/embed.ts`):
|
||
|
||
- Tracks default to a standalone `<audio>` tag wrapped in a `<p>` with the
|
||
track metadata linked back to the Songhive page. For albums, artists,
|
||
playlists, and libraries this is replaced by a `<div>` listing one
|
||
`<audio>` element per track — only offered when the collection holds
|
||
fewer than 250 elements, and limited to public tracks with a playable
|
||
`audio_url`.
|
||
- A Markdown link `[{artist} - {title}]({url})` (title or filename
|
||
fallbacks for tracks, the entity name for collections).
|
||
- A `<script>` embed: a `data-songhive-embed` placeholder rendered by
|
||
`frontend/public/embed.js` (served as `/embed.js`) into an iframe of the
|
||
SPA's standalone `/embed/{type}/{id}` route (`views/EmbedView.vue`) — a
|
||
compact track player for tracks and an expandable tracklist for
|
||
collections. The embed page reports its content height via `postMessage`
|
||
and the script resizes the iframe to match.
|
||
- A no-JS `<iframe>` fallback pointing at the same `/embed/{type}/{id}`
|
||
route with fixed heights.
|
||
|
||
Embed URLs load anonymously, so the tab only offers snippets for public
|
||
items (owners see a publish-first hint otherwise).
|
||
|
||
---
|
||
|
||
## Feeds
|
||
|
||
`api/routes/feeds.py` serves RSS 2.0 and Atom 1.0 documents under `/feeds`
|
||
(outside `/api/v1`, like `/webmentions` and `/ap`), rendered by
|
||
`services/feeds.py` with standard-library XML escaping — no feed library is
|
||
involved. Each feed is available in both formats by swapping the URL suffix:
|
||
|
||
| Feed URL | Content |
|
||
|-------------------------------------------------|-----------------------------------------------------|
|
||
| `/feeds/users/{username}.{fmt}` | The user's latest posts (`mode="posts"` timeline) |
|
||
| `/feeds/{plural}/{id}/activities.{fmt}` | Activities attached to a track, album, artist, playlist, library or radio |
|
||
| `/feeds/artists/{id}.{fmt}` | Latest releases: albums plus standalone tracks merged by date |
|
||
| `/feeds/playlists/{id}.{fmt}` | Tracks most recently added to the playlist |
|
||
| `/feeds/libraries/{id}.{fmt}` | Tracks most recently added to the library |
|
||
| `/feeds/tags/{name}.{fmt}` | Newest entities and activities carrying the tag |
|
||
| `/feeds/genres/{name}.{fmt}` | Newest tracks and albums in the genre |
|
||
|
||
`{fmt}` is `rss` or `atom` (served as `application/rss+xml` /
|
||
`application/atom+xml`); `?limit=` may reduce the page size but never
|
||
exceed `feeds.max_items` (default 20, range 1–500). The whole subsystem is
|
||
gated on `feeds.enabled`. Feed queries reuse the same ACL predicates as the
|
||
JSON API, so anonymous readers only see public content and authenticated
|
||
requests also cover `local` visibility and share grants; a valid share
|
||
token still grants access to a private collection's feed. For the artist
|
||
releases feed, a track that belongs to an album visible to the requester is
|
||
not listed separately — the album entry already represents it. Track items
|
||
carry an `<enclosure>`/Atom `rel="enclosure"` link pointing at the public
|
||
`/api/v1/stream/{id}` URL when the track has audio.
|
||
|
||
Feed discovery happens server-side so non-browser clients can find the
|
||
URLs: `api/semantic_meta.py` appends
|
||
`<link rel="alternate" type="application/rss+xml|atom+xml">` tags to the
|
||
semantic `<head>` tags it injects into the SPA shell for object pages (and
|
||
`profile_pages` does the same for `/@{username}`), mirroring whatever feed
|
||
the page exposes — track and album pages advertise their activities feed,
|
||
`/activities` sub-pages of artists/playlists/libraries switch to the
|
||
activities variant. On the frontend, `utils/feeds.ts` builds the same URLs,
|
||
`composables/useFeedLinks.ts` keeps `document.head`'s feed alternates in
|
||
sync after client-side navigation (replacing the tags the server injected
|
||
for the landing page and removing them on feed-less pages), and
|
||
`components/ui/FeedButton.vue` renders an RSS/Atom menu on every
|
||
feed-backed page (entity headers, activity pages, tag/genre detail and
|
||
user profiles).
|
||
|
||
---
|
||
|
||
## Notifications
|
||
|
||
`models/notification.py` defines `Notification` (recipient `user_id`,
|
||
`type`, optional `actor_url`/`source_url`, JSON `payload`,
|
||
`delivered_targets`, `seen_at`, `digest_sent_at`) and
|
||
`NotificationPreference` (unique per `(user_id, type)` with `in_app`,
|
||
`email`, and `email_digest` toggles). The notification types are
|
||
`follow`, `like`, `boost`, `quote`, `reply`, `mention`, `share`,
|
||
`webmention`, `activity`, `report` (admin-only: a user report was
|
||
filed — links to `/admin/reports`), and `download` (a bulk-download ZIP
|
||
archive finished building — the payload carries `archive_id`, `label`,
|
||
`size`, and `item_errors`, and `source_url` points at `/downloads`).
|
||
`ActivitySubscription` rows
|
||
record that a local user wants an `activity` notification for every
|
||
activity another actor authors — the "bell" toggle on a user profile.
|
||
Local users are referenced through `target_user_id`, remote actors through
|
||
`target_actor_url` (exactly one is set per row, each unique per
|
||
`(user_id, target)` pair, no self-subscriptions).
|
||
|
||
`services/notifications.py` creates notifications: it resolves the
|
||
recipient's per-type targets (defaults when no preference row exists are
|
||
in-app on, email and digest off), skips creation entirely when all targets
|
||
are disabled, and deduplicates on `(user, type, actor_url, source_url)`
|
||
against existing unseen rows. When `in_app` is enabled it pushes a
|
||
`notification` event over the WebSocket bus via
|
||
`EventWebSocket.send_to_user`, delivered only to the recipient's
|
||
connections. When `email` is enabled and the recipient has a verified email
|
||
address it enqueues `tasks/email.py`'s `send_notification_email`. Rows with
|
||
`email_digest` enabled are collected by the scheduled
|
||
`send_notification_digest` task, which groups pending rows per user, sends
|
||
one plain-text email, and stamps `digest_sent_at` only after a successful
|
||
send. `purge_seen_notifications` deletes seen rows older than
|
||
`notifications.retention_days` (default 90). Hook and email failures are
|
||
logged and swallowed so they never break the originating operation.
|
||
|
||
The same module also removes and rewrites notifications:
|
||
`delete_notifications` and `clear_notifications` handle recipient-scoped
|
||
manual deletion, while `retract_notifications` (filtering on recipient,
|
||
type, actor URL variants, and `source_url`) and
|
||
`retract_notifications_referencing` (matching `source_url`,
|
||
`payload.target_url`, and `payload.activity_id`; dialect-aware for
|
||
PostgreSQL vs SQLite/MySQL JSON extraction) clean up rows whose
|
||
underlying event was undone or deleted. Every deletion path pushes a
|
||
`notification_deleted` event (`{"ids": [...]}`) to each affected
|
||
recipient over the WebSocket bus. `update_notifications_referencing`
|
||
rewrites the managed snapshot fields of rows referencing a set of
|
||
activity/object URLs, `update_notifications_from_actor` refreshes actor
|
||
metadata (`actor_name`/`actor_display_name`/`actor_avatar_url`) on rows
|
||
produced by a given actor, and `refresh_notifications_for_item` updates
|
||
`item_title`/`target_item_title`/`track_title` on rows referencing a
|
||
local item; each pushes
|
||
a `notification_updated` event carrying the changed rows. Updates only
|
||
touch managed fields — unrelated payload keys and read/unread state are
|
||
preserved, and missing source fields remove stale keys. Service helpers
|
||
flush but never commit.
|
||
|
||
REST API (`api/routes/notifications.py`; every endpoint is authenticated
|
||
and scoped to the current user):
|
||
|
||
| Route | Description |
|
||
|-------|-------------|
|
||
| `GET /api/v1/notifications/` | Newest-first list, `seen` filter, `type` CSV allowlist, `limit`/`offset` + `X-Total-Count` |
|
||
| `GET /api/v1/notifications/unread-count` | Unseen count for the nav badge |
|
||
| `POST /api/v1/notifications/seen` | Mark ids seen |
|
||
| `POST /api/v1/notifications/unseen` | Mark ids unseen |
|
||
| `POST /api/v1/notifications/seen-all` | Mark all current-user rows seen |
|
||
| `DELETE /api/v1/notifications/{id}` | Delete one owned notification |
|
||
| `POST /api/v1/notifications/delete` | Bulk delete owned ids (max 500) |
|
||
| `POST /api/v1/notifications/clear` | Delete all current-user rows |
|
||
| `GET`/`PUT /api/v1/notifications/preferences` | Merged per-type delivery matrix |
|
||
|
||
`POST /api/v1/admin/notifications/purge` performs a manual retention purge
|
||
and records a `notification.purge` audit row with the deleted count and
|
||
retention window; `songhive admin purge-notifications` runs the same service
|
||
from the CLI, and the `/admin/tasks` page exposes the endpoint as a
|
||
confirmation-protected action.
|
||
|
||
Creation hooks: favoriting another user's track
|
||
(`api/routes/favorites.py`) creates a `like`, and liking another local
|
||
user's activity (`services/activities.py`'s `like_activity`, reached via
|
||
`POST /activities/{id}/like`) notifies its `owner_user_id`; creating a new
|
||
share grant (`api/routes/shares.py` via `services/sharing.py`'s
|
||
`(grant, created)` return) creates a `share`. The federation inbox path
|
||
(`tasks/federation.py` → `federation/notifications.py`) maps Follow/Like/
|
||
Announce/Create/QuoteRequest activities for each resolved local recipient
|
||
— the addressed user for per-user inboxes, `resolve_inbox_recipients`'
|
||
audience resolution for shared-inbox deliveries — `quote` wins over
|
||
`reply` when a Create is both, only the target's owner gets the
|
||
`reply`/`quote` notification (a reply or quote of someone else's post
|
||
that merely tags the recipient stays a `mention`, which is otherwise
|
||
suppressed once a reply/quote notification for the same note fired),
|
||
and a `Like`/`Announce` likewise only notifies the target's owner (a
|
||
followed actor's like or boost of somebody else's — or a remote — post
|
||
notifies nobody: the interaction is pubby's `federation_interactions`
|
||
record plus, for followed actors' announces, a materialized `announce`
|
||
row) — and stamps matching `ActivityMention.notified_at` rows. A FEP-044f
|
||
`QuoteRequest` (auto-approved by pubby, which stores a dereferenceable
|
||
`QuoteAuthorization` and answers `Accept`) also yields a `quote`
|
||
notification when its `object` resolves to a local post owned by the
|
||
recipient — the quote's own `Create` may never reach the inbox — with the
|
||
`instrument` id recorded as `source_url`. Because a quote can arrive
|
||
twice (request then `Create`, or the same `Create` on two inboxes) and
|
||
the first row may already be seen, `quote` notifications deduplicate and
|
||
merge payloads on `(recipient, actor, quoting object)` regardless of
|
||
seen state, preserving the existing row and its read marker
|
||
(`_create_or_update_quote_notification`).
|
||
|
||
Activity subscriptions fan out through
|
||
`services/activities.py`'s `_notify_activity_subscribers`, invoked by every
|
||
local producer path (`create_local_activity` for `create` types,
|
||
`create_status`, `record_track_publication`, `like_activity`,
|
||
`boost_activity`, `reply_to_activity`, `quote_activity`). Each subscriber
|
||
is filtered through `can_view_activity` — `mentioned`/`private` posts and
|
||
inaccessible entities never leak — and the author, the interaction
|
||
target's owner, and already-mentioned users are skipped so the `activity`
|
||
row stays a fallback rather than a second notification for an event a
|
||
specific `reply`/`quote`/`like`/`boost`/`mention` row already covers.
|
||
The payload snapshots the authored activity (or, for authored
|
||
likes/boosts, the reacted one) with the same `object_*`/`target_*`/
|
||
`item_*` fields the other hooks produce, plus `activity_type` so clients
|
||
can phrase the action ("shared a post", "liked a track").
|
||
|
||
Remote actors fan out the same way through
|
||
`notify_remote_activity_subscribers`, keyed on the materialized row's
|
||
`source_actor`: it is invoked by the inbox materializers —
|
||
`_materialize_remote_object` (replies and quotes),
|
||
`materialize_remote_announce`, and `materialize_remote_post` via a
|
||
`notify_subscribers` flag on `_materialize_remote_activity`, so only
|
||
inbox-delivered activities notify while explicit remote-object lookups do
|
||
not. Actor display fields come from the federation actor cache
|
||
(`resolve_source_actor_profiles`) with a `user@domain` handle fallback,
|
||
and `remote`-entity rows link the local `/activities/{id}` page since they
|
||
resolve no local entity. A remote actor `Delete` drops the subscriptions
|
||
targeting them.
|
||
|
||
Subscription state is toggled via
|
||
`POST`/`DELETE /api/v1/users/{username}/activity-subscription` for local
|
||
users and `POST`/`DELETE /api/v1/remote/actors/{handle}/activity-subscription`
|
||
for remote actors; subscribing also follows the target on a best-effort
|
||
basis (a failed follow does not fail the subscription) and the response
|
||
reports the resulting `follow_state`. The viewer's state is surfaced as
|
||
`activity_subscribed` on the public profile and remote-actor responses;
|
||
account deletion drops the rows in both directions, including any keyed on
|
||
the deleted user's actor URL.
|
||
|
||
Notification `payload`s are denormalized at creation so rows stay renderable
|
||
after the source object disappears. Every hook records the actor's
|
||
`actor_name`/`actor_display_name`/`actor_avatar_url` (the federated values
|
||
come from pubby's cached actor document); likes, boosts, and shares record
|
||
`item_type`/`item_id`/`item_title`/`local_url` when the object resolves to a
|
||
local entity (`federation/notifications.py`'s `_resolve_local_object`
|
||
matches `{actor}/objects/{id}` against `Track.federation_object_id` and
|
||
`Activity.local_object_id`/`source_id`, and `/{plural}/{id}` URLs on the
|
||
instance domain). Objects resolving to a local `Activity` additionally
|
||
record `object_activity_id`, the ActivityStreams `object_type` (`Note`,
|
||
`Audio`, …) and `object_page_url` — the activity's own page, i.e. the
|
||
entity's activity feed or the author's profile for `user` entities
|
||
(`services/activities.py`'s `activity_page_url`, shared by
|
||
`_entity_link_fields` and the object-permalink browser redirects). `user`
|
||
entities have no item page, so `item_type`/`item_id` are omitted there and
|
||
`local_url` points at the author's `/@username` profile. `Create` payloads carry a `_note_snapshot` —
|
||
`object_content` (capped raw HTML), `object_summary`, `object_name`,
|
||
`object_url`, `published`, `object_mentions` — and replies/quotes add
|
||
`target_url` plus `target_*` fields for a resolved local target. The
|
||
`RE: <link>` quote fallback remote servers embed for non-quote-aware
|
||
clients (a `quote-inline` element for Mastodon/Akkoma, a bare `RE: <url>`
|
||
tail for Misskey/Threads) is stripped from stored content and snapshots
|
||
(`strip_quote_fallback`) — the quoted activity renders through the
|
||
`in_reply_to_activity_id` embed instead.
|
||
|
||
Retraction mirrors creation so notifications don't outlive their event:
|
||
unfavoriting a track retracts the owner's `like` notification, retracting a
|
||
`like` activity removes the notification it produced (matched via
|
||
`payload.activity_id`), revoking a share grant retracts the grantee's
|
||
`share`, `retract_activity` and `cascade_delete_entity` retract
|
||
notifications referencing the removed activity/entity, and deleting a user
|
||
retracts the notifications they produced elsewhere. On the inbox path, `federation/notifications.py`'s
|
||
`retract_inbox_notifications` handles `Undo` (Follow/Like/Announce,
|
||
including undo-by-activity-id via `payload.activity_id`) and `Delete`
|
||
(object or actor) scoped to the addressed recipient.
|
||
|
||
Edits propagate too: `update_activity` and `sync_track_publications`
|
||
refresh the object snapshot on notifications referencing the rebuilt
|
||
object (retracting rows whose mention/reply/quote basis no longer
|
||
holds), and track renames refresh the stored item titles. On the
|
||
inbox path `update_inbox_notifications` handles `Update` activities —
|
||
a `Note` update refreshes the snapshot when the notification is still
|
||
applicable and retracts it when the recipient is no longer mentioned or
|
||
the reply/quote target changed away, while an actor `Update` refreshes
|
||
the stored actor metadata.
|
||
|
||
The frontend `stores/notifications.ts` owns the unread count, the paginated
|
||
list, and optimistic seen/unseen and deletion updates (with rollback +
|
||
toast on error). The list composes an `all`/`unread` read-state filter with a
|
||
multi-select type allowlist (empty = all types) sent as the `type` CSV param;
|
||
incoming events of filtered-out types still bump the badge but stay off the
|
||
list.
|
||
It registers `notification`, `notification_deleted`, and
|
||
`notification_updated` handlers on the
|
||
shared `eventBus` singleton
|
||
(`api/ws.ts`), connected when the auth store reports authenticated and
|
||
disconnected on logout; each received notification also raises an info
|
||
toast naming the actor and action, while deletions prune matching rows
|
||
and updates replace changed rows in place, both silently. `AppLayout` renders a `99+`-capped badge on the
|
||
`/notifications` nav item; `views/NotificationsView.vue` auto-marks visible
|
||
rows seen via a debounced `IntersectionObserver` batch that defers to rows
|
||
the user just toggled back to unseen, and offers per-row dismissal plus a
|
||
TrackList-style selection mode for bulk delete/read/unread and a
|
||
confirmation-protected "Clear all". Each row's header links the actor name
|
||
to the actor's internal profile (local or remote `/@name@host`) and the
|
||
action text separately
|
||
to the referenced object — for likes/boosts the `object_page_url` of the
|
||
reacted activity. The action text names the interacted entity
|
||
(`utils/notifications.ts`, shared with the WebSocket toast): likes/boosts
|
||
on `Audio` objects or resolved `item_type`s render as "liked/boosted your
|
||
track/album/…", replies/quotes do the same through their `target_*`
|
||
fields, and shares name the granted item ("shared an album with you"),
|
||
while `Note` objects and unresolved remote objects keep the
|
||
generic "post" wording. Rows render context cards from the
|
||
payload: follows (and unresolved likes/boosts) show
|
||
`components/notifications/NotificationActorCard.vue` (all actors route to
|
||
the internal profile — `/@name` locally, `/@name@host` for remote actors,
|
||
whose profile keeps the link out to the origin site),
|
||
mentions/replies/quotes embed a
|
||
read-only `ActivityCard` fed by the note snapshot (remote HTML reduced to
|
||
plain text), likes/boosts on `Note` objects fetch and embed the real
|
||
`ActivityCard` through `NotificationActivityCard.vue` (`GET
|
||
/api/v1/activities/{id}`), and shares plus likes/boosts on `Audio` objects
|
||
(or failed activity fetches) show
|
||
`NotificationItemCard.vue`, which fills in title and cover art through the
|
||
deduplicating `composables/useItemSummary.ts` cache when the payload lacks
|
||
them. The `/settings?tab=notifications`
|
||
profile tab (`views/profile/NotificationSettings.vue`) edits the per-type
|
||
in-app/email/digest matrix; the email columns are disabled until the
|
||
account email is verified.
|
||
|
||
### Mentions archive
|
||
|
||
Mention notifications are dismissible and purged by retention, and they
|
||
are never created at all when the recipient's delivery preferences drop
|
||
in-app rows or a reply/quote notification already covered the note — so
|
||
they cannot answer "which activities ever mentioned me". The mention
|
||
archive closes that gap: `models/mention_record.py` defines
|
||
`MentionRecord` (recipient `user_id`, `source`, `source_url`,
|
||
`activity_id`, `actor_url`, `visibility`, JSON `payload`), a permanent
|
||
per-user record of every activity that addressed them, backed by
|
||
`services/mention_records.py`. Rows are keyed on
|
||
`(user_id, source, source_url)` so re-delivery and edits upsert instead
|
||
of duplicating, and `payload` snapshots the same render fields the
|
||
matching notification carries (actor identity, `object_*` snapshot,
|
||
`target_*`/`item_*`/`local_url` link fields) so records stay renderable
|
||
after the source object disappears.
|
||
|
||
`source` identifies the pipeline that produced the mention and each one
|
||
writes records independently of notification delivery:
|
||
|
||
- `local` — activities authored on this instance
|
||
(`services/activities.py`'s `_record_activity_mentions`, run by
|
||
`create_local_activity`, `create_status`, `reply_to_activity`, and
|
||
`quote_activity`). Every mentioned local user is recorded — including
|
||
self-mentions and recipients already covered by a reply/quote
|
||
notification — with the activity's `source_id` as `source_url`, its
|
||
materialized `activity_id`, and its stored `visibility`.
|
||
- `activitypub` — objects received through the federated inbox
|
||
(`federation/notifications.py`'s `_record_inbox_mention`, called from
|
||
`create_inbox_notifications` whenever the note's `Mention` tags
|
||
address the recipient). Visibility is classified from the object's
|
||
addressing: `public` when it addresses the public collection,
|
||
`followers` when the author's followers collection is addressed,
|
||
`mentioned` otherwise.
|
||
- `webmention` — incoming Webmentions materialized into activities
|
||
(`webmentions/service.py`'s `_record_webmention`, called from
|
||
`materialize_webmention` for the target's owner). The deterministic
|
||
`urn:songhive:webmention:<hash>` activity `source_id` keys the record,
|
||
so re-sent mentions refresh it and the target's visibility is
|
||
mirrored.
|
||
|
||
The lifecycle mirrors the notification one so records don't outlive
|
||
their event: local edits (`update_activity`) re-resolve mentions and
|
||
delete records for recipients the edit dropped, `retract_activity` and
|
||
`cascade_visibility_update` remove or reclassify records with the
|
||
activity, federated `Undo`/`Delete` retractions
|
||
(`retract_inbox_notifications`) remove records referencing the undone or
|
||
deleted object/activity — or every record a deleted actor produced —
|
||
and `Update` handling (`update_inbox_notifications`) merges the revised
|
||
snapshot into surviving records, drops records whose `Mention` tag was
|
||
edited out, and refreshes `actor_*` fields when the actor document
|
||
itself is updated. Webmention retractions flow through
|
||
`retract_activity`. Deleting a user removes their archive and the
|
||
records their actor URL produced (`users/manager.py`'s
|
||
`_remove_user_references`).
|
||
|
||
REST API (`api/routes/mentions.py`): `GET /api/v1/mentions/` is
|
||
authenticated and scoped to the current user, newest-first, with
|
||
`limit`/`offset` + `X-Total-Count` pagination, a `source` CSV allowlist
|
||
(`local`, `activitypub`, `webmention`; unknown values ignored), and
|
||
`visibility=private` restricting to non-public records.
|
||
|
||
The frontend `/mentions` route (`views/MentionsView.vue`, authenticated)
|
||
lists the archive with source and all/private filter button groups and
|
||
load-more pagination. Each row reuses the notification card pipeline —
|
||
a real `ActivityCard` through `NotificationActivityCard` when
|
||
`activity_id`/`object_activity_id` resolves to a stored activity, a
|
||
read-only snapshot `ActivityCard` for unmaterialized remote notes, then
|
||
`NotificationItemCard`/`NotificationActorCard` fallbacks — and badges
|
||
the source, the Webmention type, and a lock for non-public records.
|
||
|
||
---
|
||
|
||
## Task Queue (Celery)
|
||
|
||
All background work is handled by Celery workers. Redis is the broker
|
||
(db 1) and result backend (db 2).
|
||
|
||
| Task module | Responsibilities |
|
||
|----------------------|-----------------------------------------------------------|
|
||
| `tasks/import_.py` | File processing, tag extraction, track/album/artist upsert|
|
||
| `tasks/federation.py`| Activity delivery, inbox processing, key provisioning, remote-activity pruning |
|
||
| `tasks/transcoding.py`| Pre-transcode to common formats, cache result |
|
||
| `tasks/email.py` | Verification, password-reset, notification emails |
|
||
| `tasks/notifications.py`| Notification digest + seen-notification retention purge (scheduled) |
|
||
| `tasks/musicbrainz.py`| MusicBrainz + Cover Art Archive metadata enrichment |
|
||
| `tasks/images.py` | Artist image + Cover Art Archive cover enrichment |
|
||
| `tasks/preview_cards.py`| Fetch + cache per-URL link-preview cards for activities |
|
||
| `tasks/downloads.py` | ZIP archive builds behind a Redis concurrency semaphore, archive retention cleanup (hourly) |
|
||
| `tasks/storage.py` | Orphaned `StoredFile` GC, audio-only hash rehash (scheduled) |
|
||
|
||
The `cleanup_orphaned_files_schedule` config accepts any 5-field cron
|
||
expression. The notification digest and retention purge run on fixed daily
|
||
crontabs at `notifications.digest_hour` (default 8 AM) and
|
||
`notifications.purge_hour` (default 3 AM).
|
||
|
||
`songhive.tasks.federation.prune_remote_activities` removes stale remote
|
||
content through `services.remote_content.prune_stale_remote_activities`:
|
||
`source_type="remote"` activities whose `published_at` is older than
|
||
`federation.remote_activity_retention_days` (default 30, overridable via
|
||
the task's `older_than_days` argument) and that no local user has
|
||
interacted with — a live like/boost/reply/quote child reached through
|
||
`in_reply_to_activity_id` protects the whole thread, so the eligible set
|
||
is computed to a fixpoint. Mirrored `remote_objects` cache rows are
|
||
removed with their activities and can always be re-fetched via remote
|
||
URL search; bare remote resources without an activity row are never
|
||
touched. The task runs manually via `POST
|
||
/api/v1/admin/federation/prune-remote-activities` (audited as
|
||
`federation.prune_remote_activities`), `songhive admin
|
||
prune-remote-activities`, or the admin Tasks page — all with dry-run
|
||
support — and only joins the beat schedule when
|
||
`federation.remote_activity_prune_schedule` holds a 5-field cron
|
||
expression.
|
||
|
||
Each task's async work is executed with ``asyncio.run(...)``. Because
|
||
``asyncpg`` connections are bound to the event loop that created them, every
|
||
task disposes the shared async engine and resets the global session factory
|
||
before the loop closes, ensuring the next task gets a fresh pool.
|
||
|
||
---
|
||
|
||
## Email
|
||
|
||
SMTP-based email is configured via the `email` config section. Celery tasks
|
||
in `tasks/email.py` enqueue verification, password-reset, and individual
|
||
notification messages asynchronously; `tasks/notifications.py` sends the
|
||
per-user daily digest. `EmailNotConfiguredError` is raised when the SMTP
|
||
host or `from_address` is missing.
|
||
|
||
---
|
||
|
||
## Track Metadata and Audio-Only Content Hashing
|
||
|
||
Uploaded audio files are deduplicated and addressed by an **audio-only SHA-256
|
||
hash**. The hash is computed with `ffmpeg -map 0:a -c copy -f streamhash` over
|
||
the raw audio bitstream, ignoring container metadata, embedded tags, and cover
|
||
art. This means:
|
||
|
||
- Two files containing the same recording but with different tags have the same
|
||
hash and share a single `StoredFile` row and storage path.
|
||
- Tags and cover art can be rewritten in place without changing `sha256` or
|
||
`storage_path`; only `StoredFile.size` is updated.
|
||
|
||
The tag rewrite is performed by the `sync_track_tags` Celery task
|
||
(`songhive/tasks/tags.py`). The task:
|
||
|
||
1. Acquires a Redis lock at `sync_tags:{track_id}` (`nx=True`, `ex=300`).
|
||
2. Loads the track with its artist, album, audio file, track image, and album
|
||
cover relations.
|
||
3. Resolves cover art in this order:
|
||
1. Track `image_file_id`
|
||
2. Album `cover_file_id`
|
||
3. No cover
|
||
4. Retrieves the audio file locally and writes the current DB metadata into the
|
||
embedded tags using `mutagen`.
|
||
5. Reconciles the track's `Genre` associations from `track.genre` or from the
|
||
parent album when the track has no explicit genre, creates the corresponding
|
||
tag associations via `genres_to_tags`, and propagates the album's
|
||
genre from the intersection of its tracks' explicit genres.
|
||
6. Updates `StoredFile.size`. For S3, re-uploads the rewritten file to the same
|
||
key; for local storage, the file is already in place.
|
||
7. Releases the lock.
|
||
|
||
Tag sync is triggered automatically by metadata-mutating API operations
|
||
(track/album/artist `PATCH`, track/album cover upload and delete) and by
|
||
successful MusicBrainz enrichment. Manual bulk triggers are provided by
|
||
`POST /api/v1/admin/sync-tags` and `songhive admin sync-tags` (with optional
|
||
`--track-id`, `--album-id`, `--artist-id`, `--library-id`, or `--all`); the
|
||
admin web UI exposes the same options under `/admin/tasks`.
|
||
|
||
Image enrichment (artist images + album covers) is normally triggered
|
||
automatically by MusicBrainz metadata enrichment. It can also be run manually
|
||
in bulk via `POST /api/v1/admin/enrich-images` and
|
||
`songhive admin enrich-images` (with `--artist-id`, `--album-id`, or `--all`,
|
||
plus `--force` to re-process already-enriched entities and `--dry-run` to
|
||
preview counts).
|
||
|
||
Artist images are resolved in this order: (1) MusicBrainz image URL
|
||
relationships, (2) Wikidata `P18` image claims via the artist's Wikidata
|
||
relationship, (3) archived/known image hosts such as `web.archive.org`
|
||
(Spotify CDN) and `i.scdn.co`. If no image can be downloaded, the artist is
|
||
not marked as enriched, so future syncs will continue to retry.
|
||
|
||
To migrate an existing library that was stored before audio-only hashing, run
|
||
`songhive admin rehash-audio` (with `--dry-run` to preview) or use
|
||
`POST /api/v1/admin/rehash-audio` from the admin UI. The task re-hashes audio
|
||
files, moves/renames the backing files to the new hash-based paths, and merges
|
||
duplicate `StoredFile` rows.
|
||
|
||
Federation keys and actor URLs can be back-filled with
|
||
`songhive admin provision-federation-keys` or `POST /api/v1/admin/provision-federation-keys`.
|
||
|
||
Admins can also inspect and control the Celery worker pool from the UI. The
|
||
`GET /api/v1/admin/celery/tasks` endpoint lists all tasks currently running on
|
||
workers (including per-task runtime, worker, args and kwargs), while
|
||
`POST /api/v1/admin/celery/terminate` accepts a list of task ids and revokes them
|
||
with `terminate=True` so they are killed on the worker(s) that are running them.
|
||
The admin UI exposes these under `/admin/celery` with bulk selection.
|
||
|
||
---
|
||
|
||
## Storage Backends
|
||
|
||
| Backend | Class | Description |
|
||
|---------|----------------|-------------------------------------------------|
|
||
| Local | `LocalStorage` | Files stored under `storage.local_path` |
|
||
| S3 | `S3Storage` | S3-compatible object storage; optional CDN prefix|
|
||
|
||
Backend is selected via `storage.backend` config. Both implement the abstract
|
||
`StorageBackend` interface (`storage/base.py`). `FileSizeLimitExceededError`
|
||
is raised when an upload exceeds `storage.max_upload_size`.
|
||
|
||
---
|
||
|
||
## WebSockets
|
||
|
||
`ws/events.py` implements a Tornado `WebSocketHandler` that:
|
||
|
||
- Validates the `Origin` header against `config.server.cors_origins`; origins
|
||
matching the request `Host` are always allowed since the SPA and `/ws` are
|
||
normally served same-origin (nginx proxies both, and Vite forwards `/ws` in
|
||
development).
|
||
- Authenticates the connecting user via a JWT access token: the `?token=`
|
||
query parameter (API clients) takes precedence, then the `access_token`
|
||
`HttpOnly` cookie, which browsers send automatically on same-origin
|
||
handshakes.
|
||
- Broadcasts real-time events (import progress, federation notifications, etc.)
|
||
to authenticated clients.
|
||
- `EventWebSocket.send_to_user(user_id, event_type, data)` serializes events
|
||
the same way as `broadcast` but delivers them only to connections owned by
|
||
the given user; it is used for the targeted `notification` event pushed by
|
||
the notification service.
|
||
|
||
`broadcast`/`send_to_user` can only see the connections living in their own
|
||
process, so both also publish an envelope to the Redis pub/sub channel
|
||
`songhive:ws-events` (via a synchronous client — Celery tasks run each job in
|
||
a fresh `asyncio.run` loop). The Tornado process runs `ws_event_subscriber`
|
||
on its IOLoop, which delivers envelopes from other processes through
|
||
`EventWebSocket.deliver_envelope`; envelopes stamped with this process's id
|
||
are skipped since they were already delivered locally. This is what makes
|
||
notifications created in the Celery worker (federated follows, likes, etc.)
|
||
reach connected clients live.
|
||
|
||
The frontend `EventBus` (`frontend/src/api/ws.ts`) reconnects dropped sockets
|
||
with exponential backoff (1s up to 30s). Since the server authenticates after
|
||
the upgrade, the backoff only resets once a connection has stayed open for a
|
||
while; a close with code `4001` (unauthenticated) additionally triggers an
|
||
access-token refresh so the retry uses fresh credentials instead of looping
|
||
on a stale token.
|
||
|
||
Because authentication happens after the handshake, the browser sees an
|
||
accepted socket that is then closed — which resets the reconnect backoff of
|
||
older clients on every attempt. To bound that churn server-side, the handler
|
||
tracks authentication failures per `(remote IP, token digest)`: the first
|
||
failure still closes immediately with `4001`, but each consecutive failure
|
||
delays the close exponentially (1s, 2s, …, capped at 30s).
|
||
|
||
---
|
||
|
||
## Frontend
|
||
|
||
Vue.js 3 + TypeScript SPA, bundled with Vite.
|
||
|
||
| File/Dir | Role |
|
||
|----------|------|
|
||
| `frontend/src/main.ts` | App bootstrap, Pinia + i18n + router mount, theme apply |
|
||
| `frontend/src/App.vue` | Root component (`<RouterView />`) |
|
||
| `frontend/src/router/` | Vue Router (history mode) with global auth/admin guard |
|
||
| `frontend/src/stores/` | Pinia stores (auth, theme, toast, confirm, player, playback session, outputs) |
|
||
| `frontend/src/components/ui/` | Headless base components (button, input, select, avatar, table, pagination, search, context menu, entity actions) |
|
||
| `frontend/src/components/feedback/` | Toast, banner, spinner, skeleton, modal, confirm dialog |
|
||
| `frontend/src/components/entity/` | Reusable entity grid/list components (e.g. `BulkEditableGrid` for bulk selection and deletion) |
|
||
| `frontend/src/components/activities/` | Activity feed components (`ActivityFeed` filter tabs + cursor pagination, `ActivityCard`, `ActivityEditModal`) backed by `stores/activities.ts` and `api/activities.ts`. `ActivityAudioPlayer` renders `Audio` attachments as a styled inline player (artwork — attachment `image`, then the author's avatar, then a note icon — title/artist/album, seek and volume controls) instead of the browser-default element; its "Play in player"/"Add to queue" actions resolve local `songhive:trackId` attachments through the tracks API into `QueueTrack`s, while federated audio is synthesized as a `remote` queue track that streams its media URL directly. All embedded activity players share a module registry so starting one pauses the others and the global player, keeping a single audio source at a time |
|
||
| `frontend/src/components/statuses/` | `StatusComposer` — shared status editor (plain text or Markdown, visibility, BCP-47 language defaulting to the browser locale, `@` mention and `#` hashtag autocomplete (hashtags sorted by popularity; mentions also cover remote actors via the `remote_users` search flag and accept `user@domain` narrowing) plus track-only attach search via `SearchBar`/`SearchSuggestions`, file uploads through `api/files.ts`). Posts through `api/statuses.ts` (`POST /statuses/`) by default; the share dialog's Fediverse tab injects a custom submit that calls `tracks.publishTrack` instead, and `ActivityEditModal` reuses it for edits (`initial*` props seed the existing text/format/language/attachments; attachment chips map to `songhive:fileId`/`songhive:trackId`-marked docs). |
|
||
| `frontend/src/components/user/` | Reusable user display components (`UserLink`) used across activity cards, resource owner metadata, file details, audit logs, and admin lists. `UserLink` renders local users as `RouterLink`s to `/@{username}`, remote users as external links to `actor_url`, and accepts either a full `UserSummary` owner or legacy `username`/`displayName`/`avatarUrl`/`remoteUrl` props |
|
||
| `frontend/src/components/admin/` | Admin-specific shared components (e.g. `StatCard` for the dashboard) |
|
||
| `frontend/src/components/player/` | Persistent player bar (`PlayerBar`, `NowPlaying`, `QueuePanel`, `VolumeControl`, `OutputSelector`) mounted in `AppLayout` so playback survives route changes, backed by `stores/player.ts` and the singleton `player/engine.ts` (dual `HTMLAudioElement` primary/preload). `OutputSelector` routes playback between "This device" and configured server-side outputs via `stores/playback.ts`; output management lives in the profile Outputs tab (`views/OutputsView.vue`, `stores/outputs.ts`). `QueueTrack` extends `TrackResponse` with `stream_url` — a direct media URL used instead of `/api/v1/stream/{id}` for audio without a local track row — and `remote`, which suppresses library links and listen-history reporting |
|
||
| `frontend/src/layouts/` | App, auth, and admin layouts |
|
||
| `frontend/src/views/` | Page-level components, including `views/admin/` (Dashboard, Users, Settings, Reports, Invites, Audit, Tasks, Celery) behind the `/admin` guard (Home, Library, Album/Artist/Track/Playlist lists and details, History, Favorites, Files, File detail, Downloads, Radio station list/create/play, About, Login, Register, PasswordReset, VerifyEmail, `/settings` for the authenticated user, `UserProfileView` for `/@{username}`, `UsersDirectoryView` for `/users`, `SearchView` for the public `/search` page, plus 403/404 and placeholder views). `SearchView` renders grouped, independently sortable and paginated sections for every searchable entity via `useSearchSections`; the shared `SearchBar` supports an optional autocomplete mode backed by the aggregate `/api/v1/search/` endpoint, with `SearchSuggestions` offering arrow-key navigation (Enter picks the highlighted item) via an exposed `handleKeydown` hook the controlling input forwards to. When the caller passes `remote` (the `remote_available` flag from the search response) and the query looks federated — an `https://` URL or an `@user@domain` FQN — `SearchSuggestions` appends a "See on the Fediverse" entry that emits `remote-lookup`; `SearchView` routes it to `/remote/lookup`. `UserProfileView` renders user bios through the `RichText` component, which linkifies hashtags, mentions and URLs; library, playlist, album, and track detail views render the owner through the `UserLink` component |
|
||
| `frontend/src/api/` | Typed HTTP client (`openapi-typescript` generated `types.ts`), per-resource modules including `admin.ts` for the admin panel, WebSocket event bus, stream URL helper |
|
||
| `frontend/src/i18n/` | `vue-i18n` setup with lazy-loaded locales |
|
||
| `frontend/src/styles/tokens.css` | CSS custom properties for theming |
|
||
|
||
Browser authentication is cookie-based: the SPA never stores JWTs. The
|
||
`api/client.ts` fetch wrapper sends `credentials: "same-origin"` on every
|
||
request, echoes the readable `csrf_token` cookie as `X-CSRF-Token` on unsafe
|
||
methods, and retries once after a cookie refresh on 401. The auth store
|
||
(`stores/auth.ts`) persists only the non-sensitive user profile and bootstraps
|
||
by fetching `/users/me`; stream URLs (`api/stream.ts`) and the WebSocket
|
||
handshake (`api/ws.ts`) carry no token because the same-origin requests
|
||
authenticate through the `HttpOnly` cookies. This assumes same-origin
|
||
SPA/API hosting — which the production static serving and the Vite dev
|
||
proxy both provide — while non-browser clients keep using the token pair in
|
||
login/refresh JSON responses with `Authorization: Bearer` headers.
|
||
|
||
Entity detail pages expose an "Activities" action that navigates to
|
||
`/{entity}/{id}/activities` (`EntityActivitiesView`, shared across `track`,
|
||
`album`, `artist`, `playlist`, and `library`). The feed reads
|
||
`GET /api/v1/{entity_type}/{entity_id}/activities` with `activity_type` /
|
||
`source_type` filters and keyset `cursor` pagination; liking or boosting an activity
|
||
calls `POST /api/v1/activities/{id}/like` / `/{id}/boost`, replying calls
|
||
`POST /api/v1/activities/{id}/reply`, and editing one calls
|
||
`PATCH /api/v1/activities/{id}` (shown to the activity owner and admins).
|
||
`ActivityCard` renders a Mastodon-style action bar — reply, boost and like
|
||
icons with their counters — for authenticated users when the activity's
|
||
`can_interact` flag allows it (the icons act as toggles — liking/boosting
|
||
a reacted activity retracts it via `DELETE /{id}/like` / `/{id}/boost`;
|
||
clicking the like/boost
|
||
count opens `ActivityActorsModal` listing the known interactors from
|
||
`GET /{id}/likes` / `/{id}/boosts`, and clicking the reply count expands
|
||
the known replies from `GET /{id}/replies` — local replies rendered as
|
||
nested `ActivityCard`s, remote ones as `ActivityRemoteReply` rows —
|
||
together with a `StatusComposer` wired to the reply endpoint). Card
|
||
content is never rendered as raw HTML — `ActivityCard` reduces it to safe
|
||
segments (text, line breaks, linkified mentions/hashtags/URLs) via
|
||
`utils/activityContent.parseActivityContent`; inline formatting elements
|
||
(`<strong>`, `<em>`, `<code>`, `<del>`, `<u>`, headings) are preserved as
|
||
`marks` on the segments and rendered through CSS classes rather than real
|
||
markup, and `<ul>`/`<ol>` items flatten to bullet/numbered lines with
|
||
indentation per nesting level. `ActivityCard` also renders
|
||
the activity's `attachments` (the AP `attachment` documents of the embedded
|
||
object): image media types inline, `Audio`/audio media types in an
|
||
`<audio>` player, everything else as a link. Reaction cards
|
||
(`like`/`announce`) are wrappers: `ActivityObjectEmbed` fetches the reacted
|
||
activity through a shared per-id cache (`utils/activityFetch.ts`) and
|
||
renders the full `ActivityCard` for `Note` objects or the compact item
|
||
card for `Audio` ones (falling back to an "unavailable" placeholder on
|
||
fetch failure), the card's timestamp/copy links point at the reaction's
|
||
`object_url`, and the Delete action retracts the caller's reaction
|
||
(`stores/activities.retractReaction`) rather than deleting the target.
|
||
|
||
`UserProfileView` (`/@{username}`) shows a Compose button to the profile
|
||
owner that opens `components/statuses/StatusComposer.vue` in a modal; the
|
||
share dialog's Fediverse tab reuses the same component for track
|
||
publication, and the default post format is configurable from
|
||
`/settings` (`profile.status_content_type`).
|
||
|
||
The home page (`HomeView`, `components/home/`) is a hybrid shelf + feed
|
||
layout that splits by audience and puts the two zones behind
|
||
`Music | Activity` `AppTabs` (shelves first) so the feed no longer trails
|
||
below the whole catalogue; both panels stay mounted (`v-show`) so tab
|
||
switches never refetch. Authenticated visitors get a greeting with
|
||
a `StatusComposer` modal, at most four "your music" shelves (Jump back in
|
||
from `/history/` deduplicated by track, Favorites, Your uploads, New on
|
||
this instance), and the scoped activity feed. Anonymous visitors get the
|
||
instance hero (name, description, sign-in/register CTAs, optional
|
||
`public_stats_enabled`-gated counts from `GET /api/v1/instance/stats`),
|
||
public-catalogue shelves (recently added albums/tracks, public libraries,
|
||
people), genre chips, and the public instance feed. Every shelf fetches its
|
||
own data, renders a skeleton while loading, offers inline retry on error,
|
||
and removes itself entirely when empty. The feed (`HomeFeed`, backed by
|
||
`stores/timeline.ts` and `GET /api/v1/timeline`) offers a scope switch on
|
||
its own row — `Mine | This instance | Federated` for signed-in users,
|
||
`This instance | Federated` for anonymous visitors — and a
|
||
`Posts | All activity` mode switch on a second row. `This instance` only
|
||
surfaces locally sourced activities; `Federated` adds activities received
|
||
from remote instances and webmentions. Signed-in users default to `Mine`
|
||
when they have own activity, and the last explicit choice persists in
|
||
`localStorage` (a stored `Mine` is ignored once logged out).
|
||
|
||
The frontend is also a Progressive Web App. `/manifest.webmanifest` is served
|
||
from the backend so the PWA name follows the configured instance name and the
|
||
manifest `theme_color`/`background_color` react to the user's selected
|
||
light/dark theme. `frontend/public/pwa/` contains generated icon variants
|
||
(including `maskable-*` icons for Android adaptive icons), and
|
||
`frontend/public/sw.js` provides a lightweight offline shell cache.
|
||
|
||
Build output is served as static files by the backend (or a CDN). The FastAPI
|
||
app sets `router.default` to an ASGI handler that serves files directly from
|
||
`songhive/static/` and falls back to `index.html` for unhandled non-API paths,
|
||
so the Vue Router handles deep links such as `/verify-email?token=...`.
|
||
|
||
When the fallback serves the SPA shell for an object page (`/tracks/{id}`,
|
||
`/albums/{id}`, `/artists/{id}`, `/playlists/{id}`, `/libraries/{id}`,
|
||
`/genres/{name}`, `/tags/{name}` and `/@{username}` plus their sub-pages),
|
||
`api/semantic_meta.py` injects semantic `<head>` tags into it: OpenGraph
|
||
`og:title`/`og:description`/`og:url`/`og:type`/`og:site_name`/`og:image`
|
||
metadata for social-media preview cards, and `<link rel="tag">` elements for
|
||
the entity's hashtags. Ownership and related entities are expressed with the
|
||
OpenGraph music namespace and HTML authorship annotations: `music:musician`
|
||
links tracks and albums to their artist page, `music:album` links a track to
|
||
its album, `music:creator` links playlists/libraries to their owner, and
|
||
every uploadable entity also carries `<link rel="author">`, `name="author"`
|
||
and `fediverse:creator` tags pointing at the uploading user. Lookups go
|
||
through the regular ACL checks so private entities never leak metadata, and
|
||
any failure falls back to the unmodified shell.
|
||
|
||
Single-user mode is configured through the `single_user_username` admin
|
||
setting. When set, anonymous browser requests to `/` are answered with a
|
||
302 redirect to `/@{username}` by the always-mounted `profile_pages`
|
||
route, and the SPA router guard applies the same anonymous-only redirect
|
||
for client-side navigations; authenticated users keep the regular home
|
||
page and ActivityPub clients still get the SPA shell as before. Every
|
||
`/` response in this mode also advertises `rel="me"` links to the
|
||
single-user profile — both the `/@{username}` and `/users/{username}`
|
||
forms, injected into the SPA `<head>` and the `Link` header (the header
|
||
is the only channel on the redirect, which has no body) — so link
|
||
verification (e.g. on Mastodon) succeeds even when only the instance
|
||
base URL is referenced. The setting is exposed as
|
||
`single_user` on `/api/v1/instance` and `/api/v2/instance`.
|
||
|
||
The Vite build also copies the `swagger-ui-dist` bundle into
|
||
`songhive/static/swagger-ui/` and rewrites `swagger-initializer.js` to point
|
||
at the instance's own `/openapi.json`. FastAPI mounts those assets at
|
||
`/swagger-ui/` (with a redirect from `/swagger-ui`), so interactive API docs
|
||
are available on every deployment without a separate container.
|
||
|
||
---
|
||
|
||
## API Design
|
||
|
||
REST API under `/api/v1/`:
|
||
|
||
```
|
||
/api/v1/
|
||
├── auth/ # Login, register, token refresh, password reset, verify email, sessions
|
||
├── users/ # Public user profiles, directory, per-user activity feeds; authenticated profile updates
|
||
├── artists/ # Artist CRUD + search
|
||
├── albums/ # Album CRUD + search
|
||
├── tracks/ # Track CRUD + search
|
||
├── search/ # Lightweight aggregate multi-entity search (tracks, albums, artists,
|
||
│ # playlists, libraries, users, tags, genres) with normalized result
|
||
│ # items; ACL-filtered like the underlying list endpoints.
|
||
│ # A q starting with '#' is a hashtag lookup: the prefix is stripped
|
||
│ # and only the tags section is returned, sorted by item_count
|
||
│ # (a bare '#' lists the most used tags). With remote_users=1 the
|
||
│ # users section also matches remote actors cached by pubby
|
||
│ # (federation_followers + federation_actor_cache), returning
|
||
│ # 'user@domain' handles for mention completion
|
||
├── files/ # Generic file upload/list/download (StoredFile)
|
||
├── downloads/ # Bulk-download ZIP archives: create (explicit ids or
|
||
│ # album/artist/playlist/library container), list, status,
|
||
│ # file download, delete, clear completed; per-user active
|
||
│ # cap + rate limits, built asynchronously by Celery
|
||
├── libraries/ # Library management + add/remove tracks/albums/artists
|
||
├── playlists/ # Playlist CRUD + add/remove/reorder tracks/albums/artists + list tracks
|
||
├── favorites/ # Favorites/bookmarks
|
||
├── history/ # Listening history
|
||
├── radios/ # Dynamic radio generation
|
||
├── timeline/ # Cross-entity activity feed: scope=mine|instance (mine
|
||
│ # requires auth; anonymous defaults to instance),
|
||
│ # mode=posts|all with include_boosts/include_replies/
|
||
│ # source_type filters, keyset cursor pagination.
|
||
│ # No `following` scope — Songhive only receives follows
|
||
├── instance/ # Public instance metadata (Mastodon-compatible);
|
||
│ # `single_user` reports the single_user_username setting,
|
||
│ # /instance/stats exposes visibility-filtered counts when
|
||
│ # the public_stats_enabled admin setting is on (404 otherwise)
|
||
├── statuses/ # Standalone status posts (user-entity `create` activities with
|
||
│ # content type, language, file/track attachments, and mentions)
|
||
├── shares/ # Share grants (owner → specific user); /shares/mine lists the
|
||
│ # grants + tokens created by the current user (revoked
|
||
│ # tokens only with ?include_revoked=true)
|
||
├── share-urls/ # Share URL tokens (revocable short links)
|
||
├── share/{token} # Public short-URL resolver
|
||
├── reports/ # Content moderation reports (submit)
|
||
├── admin/ # Admin: settings, stats, user management, report review
|
||
└── stream/{id} # Audio streaming (Tornado handler, bypasses FastAPI)
|
||
```
|
||
|
||
**Federation endpoints** (mounted by pubby when federation is enabled):
|
||
|
||
```
|
||
/users/{username} # Per-user ActivityPub actor document (AP clients) or browser redirect to /@{username}
|
||
/users/{username}/objects/{id} # Dereferenceable ActivityPub objects (Audio, Note, Tombstone, stored payloads)
|
||
/users/{username}/quote_authorizations/{id} # FEP-044f QuoteAuthorization documents issued for the user actor
|
||
/@{username} # Mastodon-style profile: AP actor for AP clients, SPA shell for browsers with rel="me" links + OpenGraph tags
|
||
/tracks/{id} # Track page: Audio object for AP clients, SPA + rel=alternate hints + OpenGraph tags for browsers
|
||
/.well-known/webfinger # WebFinger discovery
|
||
/.well-known/nodeinfo # NodeInfo discovery document (pubby)
|
||
/nodeinfo/2.{0,1}[.json] # NodeInfo document (Songhive): pubby usage stats plus
|
||
# metadata.nodeName/nodeDescription, metadata.maintainer
|
||
# (configured contact person) and metadata.staffAccounts
|
||
# (actor URLs of active admins)
|
||
/ap/actor # Instance-level Application actor
|
||
/ap/inbox # Shared inbox (Songhive route; queues process_incoming)
|
||
/api/v1/*/ # Mastodon-compatible API (pubby adapter), except /api/v1/instance which is provided by Songhive and always available
|
||
```
|
||
|
||
---
|
||
|
||
## Deployment
|
||
|
||
Docker Compose (`docker-compose.yml`) provides a reference deployment:
|
||
|
||
- `setup` — one-shot root container that creates `./volumes` subdirectories
|
||
and chowns them to `PUID`/`PGID`, so the other (rootless) services can
|
||
write to them
|
||
- `songhive` — application container (Tornado server)
|
||
- `worker` — Celery worker container (same image, different command)
|
||
- `watcher` — external-library filesystem watchdog (same image)
|
||
- `stream-worker` — server-side audio outputs (`streams` profile)
|
||
- `postgres` — PostgreSQL database
|
||
- `redis` — Redis (broker + cache + sessions)
|
||
- `nginx` — Reverse proxy (`docker/nginx.conf`). It proxies everything to the
|
||
application, so the REST API, federation endpoints, frontend assets and the
|
||
SPA shell are all served by FastAPI (`api/app.py` sets `router.default` to a
|
||
handler that serves `songhive/static/` files directly and falls back to
|
||
`index.html`). Serving the SPA through the backend lets it inject `rel="me"`
|
||
links, `rel="alternate"` ActivityPub hints and OpenGraph/`rel="tag"` metadata
|
||
into object pages (`api/routes/profile_pages.py` and `api/semantic_meta.py`),
|
||
and perform the ActivityPub/browser content negotiation for routes that
|
||
double as dereferenceable AP objects. Only two paths get special treatment:
|
||
`/ws/` needs the WebSocket upgrade headers and `/api/v1/stream/` disables
|
||
response buffering for real-time audio delivery.
|
||
|
||
Persistent data is stored under `volumes/`. `config.toml` sits next to
|
||
`docker-compose.yml` and is bind-mounted read-only at
|
||
`/etc/songhive/config.toml`; `.env` (see `.env.example`) holds `PUID`/`PGID`,
|
||
the host HTTP port and `SONGHIVE_*` overrides, which are injected into the
|
||
Songhive containers via `env_file`.
|
||
|
||
### systemd
|
||
|
||
For non-Docker deployments, `config/systemd/` contains unit files for running
|
||
Songhive as a systemd service. The `install.sh` script automates the setup:
|
||
|
||
- Creates a Python virtual environment
|
||
- Installs Songhive from the local checkout
|
||
- Copies `config.toml.example` to the system (`/etc/songhive`) or user
|
||
(`~/.config/songhive`) config directory
|
||
- Creates data, cache, and log directories
|
||
- Installs the systemd units and sets the correct `ExecStart` paths
|
||
|
||
The master `songhive.service` unit starts four dependent units:
|
||
|
||
- `songhive-server.service` — main web server (`songhive`)
|
||
- `songhive-celery.service` — Celery worker and scheduler
|
||
- `songhive-watch-extlib.service` — external-library watchdog
|
||
- `songhive-stream-worker.service` — audio stream worker for server-side outputs (`songhive stream-worker`)
|