Image::handleImageTransform derives the output filename from the current
media_path and applies the encoder's output extension. When that differs from
what is already stored (heic/avif -> jpg, or a thumbnail regenerated to a new
extension), the new file landed at a different path and the previous file was
left orphaned in the media directory — the source of the leftover _thumb files
under public/m/_v2.
Capture the path each transform supersedes and delete it after a successful
write (only when the new output path differs, so we never delete what we just
wrote). Remove the stale MediaDeleteLeafCleanupTest whose source change is not
in the tree.
- Drop --except (--only already covers task selection)
- Quiet by default; per-root/per-item and summary lines now require -v/--verbose
- Errors are always shown regardless of verbosity
- Document the command in the console README
Replace the remcache GC (GarbageCollectorRemcache / gc:remcache) with a
broader storage:maintenance command that sweeps stale remcache temp files and
recursively removes the random empty directories accumulated under the media,
story, avatar and import trees (--hours/--only/--except/--dry-run), scheduled
daily.
Fix the root causes so flows clean up after themselves rather than relying on
the sweep:
- MediaDeletePipeline removes its own emptied m/_v2 leaf dir
- AvatarOptimize logs the previously-swallowed exception, still cleans up the
old avatar on failure, and removes the old file's now-empty splayed dir
- AvatarController::deleteAvatar removes the emptied splayed dir
- StoryExpire/StoryDelete remove the story's own emptied leaf dir
- TransformImports removes imports/{userId} once its files are moved out
- StoryFetch cleans up its remcache temp file in a finally block
MediaMoveStorageLocalToCloud is stable, so move it back out of the Unstable
namespace: restore App\Console\Commands\Admin\MediaMoveStorageLocalToCloud and
its admin:MediaMoveStorageLocalToCloud signature, and update the scheduler,
README, and feature test. CloudToLocal and CloudToCloud remain under unstable:.
- Rename App\Console\Commands\Admin\MigrateLocalS3MediaURL to MediaUpdateS3CDNUrl
(class + filename only; the admin:MigrateLocalS3MediaURL signature is unchanged)
- Move the three MediaMoveStorage{LocalToCloud,CloudToLocal,CloudToCloud} commands
into App\Console\Commands\Admin\Unstable and change their signatures from
admin: to unstable:
- Update the scheduler in bootstrap/app.php, the command README, the
config/filesystems.php reference comment, and the affected feature tests
Modernize artisan and public/index.php to the streamlined bootstrap form,
migrate factories/seeders autoload to PSR-4 (database/seeds -> seeders), and
backfill missing env-driven config keys across app, session, database, queue,
mail, logging, and cache. All changes are additive and preserve existing
Pixelfed behavior and defaults.
Modernize artisan and public/index.php to the streamlined bootstrap form,
migrate factories/seeders autoload to PSR-4 (database/seeds -> seeders), and
backfill missing env-driven config keys across app, session, database, queue,
mail, logging, and cache. All changes are additive and preserve existing
Pixelfed behavior and defaults.
Performance:
- TrendingHashtagService: batch-load hashtags with whereIn/keyBy instead
of Hashtag::find() per trending row.
- DirectMessageController: eager-load status.media and read the in-memory
collection instead of firstMedia() issuing a query per DM message.
- GroupsSearchController: batch Profile/Follower/GroupInvitation lookups
with whereIn instead of per-invitee queries.
Lint/tests:
- ComposeController: whitespace formatting (Pint).
- ComposeControllerTest: correct App\User to App\Models\User, fixing the
larastan class.notFound error and import ordering.
Full suite: 547 passed. Pint and PHPStan clean.
Remove deprecated Foundation\Support\Providers\AuthServiceProvider and
EventServiceProvider base classes. Move policy and event listener
registrations into AppServiceProvider using Gate::policy() and
Event::listen(). Behavior is unchanged (verified via event:list and
auth test suite).
Back out all emoji cloud-storage work from staging so it can be reworked and
re-landed separately (the URL resolution flips to cloud on a global config
flag, which created a broken-URL window, and the migration approach needs
revisiting).
Reverts to pre-emoji state:
- CustomEmoji model URL/storage helpers (urlForPath, storageTarget, storeMedia,
storeMediaFromFile, deleteMedia, url) and callers in ImportEmojis,
CustomEmojiService, AdminController
- admin custom-emoji blade views back to local /storage URLs
- Remove admin:EmojiMoveStorageLocalToCloud command
- Remove the deploy migration and its scheduler entry
Media (and the already-reverted story) scheduler entries are untouched.
Process-level workers plateaued at ~7.5 uploads/sec against Fastly Object
Storage because each PUT is high-latency and only a handful ran concurrently.
Add --concurrency=N which uses the AWS SDK CommandPool to keep N PutObject
requests in flight from a single process. A successful PutObject response is
the confirmation (no separate HEAD verify), and the local file is deleted on
success. Commands are yielded lazily so memory stays flat over large runs.
--no-acl escape hatch for S3-compatible stores that reject the ACL header.
- Live 'up/s' rate shown on the progress bar during single-worker runs
- Final summary reports elapsed time and uploads/sec
- Parallel runs tally moved across workers and report aggregate uploads/sec
Makes it easy to compare --workers counts and decide whether async S3
(option 2) is worth pursuing.
The migration was ~1-2s/file due to sequential S3 round-trips (HEAD + PUT +
verify HEAD). Speed it up:
- --workers=N spawns N child processes, each handling a strided slice of the
files (index % N == shard) for real concurrency on the I/O-bound uploads
- --skip-cloud-check skips the upfront HEAD (always upload, idempotent)
- --skip-verify skips the post-upload size re-check
- --offset for manual chunking
Storage/Flysystem has no batch or async upload API, so process-level
concurrency is the pragmatic lever here.
The frontend renders a hardcoded /storage/emoji/missing.png local onerror
fallback for emoji, so that placeholder must stay on local disk. Skip it in
the migration so it is never moved to cloud or deleted locally.
The migration was DB-driven (whereNull('uri')), which excluded federated
emoji whose media is stored locally but have a uri set -> the disk was never
scanned, resulting in moved=0.
- Drive the migration by enumerating local files under public/emoji/ instead
of a DB query; the local file is the source of truth for what needs moving
- Add --debug to print config, custom_emoji table breakdown, local emoji dir
contents, and per-file decisions
Back out the story local->cloud migration so we can land and verify the
emoji cloud work first, one change at a time.
Reverts:
- 9f110bb74 feat: migrate local story media to cloud storage
- 842681b99 fix: schedule StoryMoveStorageLocalToCloud command
Removes StoryMoveStorageLocalToCloud command, its scheduler entry, and the
StoryExpire explicit-disk changes. Remcache and emoji work are untouched.
Will revisit story once emoji is confirmed.
Two issues prevented emoji from serving/migrating correctly on cloud:
- Admin custom-emoji views hardcoded url('storage/'.media_path), so they
always showed local URLs and bypassed cloud resolution. Use $emoji->url().
- The migration/command guard relied solely on config_cache('pixelfed.cloud_storage'),
which is DB/12h-cached and can read stale-false right after cloud is
enabled, causing the migration to silently no-op. Treat cloud as enabled
when either live config() or config_cache() is true.
The story cloud-migration command was merged (#6958) but its scheduler
entry was dropped when the emoji branch (based off staging before that
merge) later merged and overwrote the scheduler block. Restore the hourly
schedule so local story media is migrated to cloud automatically.
Runs admin:EmojiMoveStorageLocalToCloud during migrate so existing local
emoji are relocated to cloud as part of the upgrade, shrinking the window
where emoji URLs resolve to cloud before the files are there. No-op unless
cloud storage is enabled.
Change --limit default to 0 (no limit) so the emoji migration processes
every local emoji in a single run instead of capping at 1000, and drop the
limit from the scheduled invocation. Avoids a multi-run window where
not-yet-migrated emoji resolve to missing cloud URLs.
Custom emoji were always written locally and served via hardcoded /storage
URLs, so they never used S3 even on cloud instances.
- CustomEmoji: centralize URL + storage on the active disk (cloud when
pixelfed.cloud_storage is enabled, else local public/ disk) via
urlForPath/url/storageTarget/storeMedia/storeMediaFromFile/deleteMedia
- Route emoji writes/deletes and URL generation (scan, CustomEmojiService::all)
through those helpers in ImportEmojis, CustomEmojiService::import and
AdminController
- Add admin:EmojiMoveStorageLocalToCloud to migrate existing local emoji to
cloud: copy, verify by size, delete local, bust caches
- Schedule it daily when cloud storage is enabled
Ensure story media lands on and stays on cloud storage for S3 instances.
- StoryExpire: archive expiring story media on the same explicit disk the
media lives on (S3 move is a server-side copy+delete), with error handling
- Add admin:StoryMoveStorageLocalToCloud to migrate local story media
(active + story_archives) to cloud: copy, verify by size, then delete local
- --orphans option relocates untracked story_archives/ files to cloud using
the same copy/verify/delete flow (media is moved, never discarded)
- Schedule it hourly alongside the media migration when cloud storage is on