0042. Media is found before it is created, and created when its document is written¶
Status: Accepted
Date: 2026-10-31
Context¶
0038 converts formatted text into a document, 0039 writes a field's documents in place, 0040 keeps markup no rule converts, iframe, drupal-media and drupal-entity among it, and 0041 resolves files and content. Media was left to the submodule that provides the media block, which stores an item's uuid and a view mode (0006), treats the permitted view modes as curation (0015), holds an alignment and no width (0018) and may override an image item's alternative text with alt and decorative (0036).
What sites store: core's media embed writes <drupal-media data-entity-type="media" data-entity-uuid="…">, with an optional data-view-mode, data-align, data-caption and alt, and its media_embed filter shows the item only for exactly that type and a uuid that is not blank, in the view mode given or the filter's default_view_mode, and applies alt to an image item whose field has alternative text enabled, the two characters "" and an empty value both making it empty. entity_embed writes <drupal-entity data-entity-type="media"> with the display it chose, view_mode:media.<mode> or a field formatter, and its entity_embed filter shows it. Pages that embed a video carry the provider's player, an iframe whose address takes many forms, and video_embed_field's token, which 0038 turns into one. The early adopter's notes count images named by their file's uuid, with data-align, data-caption and data-is-zoomable, a hundred YouTube and Scribd players, almost seven thousand video tokens and nearly ten thousand embed codes stored base64-encoded in a custom element.
A media item holds an image by its image source field and a remote video by an oEmbed source field holding an address. Core finds a video's provider by matching the address against the URL schemes of its provider list, which it fetches from oembed.com and stores for a week, and when no scheme matches it fetches the address itself to look for one. Saving a new item, Media::prepareSave(), asks the source for each mapped field that is empty, for the name when it is empty, for the thumbnail unless the type queues its thumbnail downloads, and for the thumbnail's alternative text where the source declares an attribute for it; for an oEmbed source each of these fetches the resource from the provider. MediaStorage does this outside the save's transaction, and a type that queues its thumbnails leaves them to cron. Core's image source reads its metadata from the file on the server. A private file is served to whoever may view an entity that references it, a media item included.
A conversion stores nothing (0038); a field's conversion writes each revision under its host's lock, in a transaction, and a dry run writes nothing (0039). Nothing in the converter fetched anything from the network.
Decision¶
Media rules live in the submodule, on the base module's seam. blocknote_media registers three element rules at priority 10, DrupalMediaRule for drupal-media and drupal-entity, ImageMediaRule for img and RemoteVideoRule for iframe, so that they are asked before the base module's image and embed rules, and each passes an element it does not convert to those. None converts anything where the target format does not enable the media block. The walker reports the classes of an element they convert, as it does for the base module's own rules: they know no site's classes either.
A media item is found through a third kind of resolver. Drupal\blocknote_media\Convert\MediaResolverInterface::resolveMedia(MediaReference, ConversionContext): ?MediaInterface is a conversion rule on the same tag, asked highest priority first, the first answer winning; MediaFinder asks the resolvers, which it takes lazily from the tagged rules, since the module's own rules depend on it. A MediaReference holds what the markup says: an item's uuid, an image's file as the file resolvers of 0041 found it, or a remote address and its provider, with the media types the target format permits and the element. The module's own resolver, MediaLookup, at priority 0, finds an item by its own uuid, in lower case; an image's item by the file its image source field holds; and a remote item by the address its oEmbed source field holds, read as the video it names, among the permitted types, a published item, in the host translation's language, winning over one that is not and the first saved over the others. Finding reads the site's own data and nothing else, and asks no access, as 0041 has it.
An item found by what it holds is named only where a reader of the page would see it. A page showed an image or a player to each of its readers, and a media block shows an unpublished item only to those who may view unpublished media. So MediaFinder names an unpublished item that a resolver found for a file or an address, or that has the uuid an item would be created with, only where the items the conversion creates are unpublished too, as for a draft; an older revision saved as the default one counts as the page it was, which a revert makes it again, so a revision converted while current and again once it is older gets the same document; otherwise the image stays an image block and the player is kept, reported as media_not_published, and no other item is created in its place, since two items would then hold one file. Amended 2026-11-03. Except an item a conversion created unpublished that nobody has changed since, which a page whose readers see it, in the same run or a later one where the conversion creates items, names and publishes when its document is written, through ConversionContext::publishOnWrite(), reported as would_publish and then published: an item is unchanged where its uuid is the one the conversion derives for what it holds and its only revision is the one the conversion saved, with the conversion's log message, which cron's thumbnail and title queues leave as they are and an edit through the media form does not. A dry run sees only items that exist, so it reports would_create for a later page naming an item an earlier document of the same run would create. An item found by its uuid is named whatever its status, as the page named it.
Amended 2026-11-03. The amendment above overstated what its check proves. A media type saves without a new revision by default, so a save in place, which code and core's action unpublishing media make, keeps the one revision and the log: an item changed that way, or one created published that someone unpublished, passed the check and was published again by a later run, and the media form failed it only because it empties the log. Only the job that created an item unpublished publishes it now. A job is given an id when BulkConverter::begin() starts it, its key and a uuid, which every run resuming it keeps and --restart replaces, and the writer hands it to each conversion as ConversionSource::$job. An item a job creates has the revision log message "Created unpublished by the BlockNote converter in job" and the id where it is created unpublished, and "Created by the BlockNote converter in job" and the id otherwise; one created outside a job keeps the message without a job. MediaFinder asks for an unpublished item to be published only where the conversion has a job, the item's log is that job's unpublished one, its uuid is the derived one and its only revision is the one the job saved. publishOnWrite() takes that check as a closure, which the writer runs on the item read again, past the entity caches, inside the write's transaction; where the item is still unpublished and fails it, or is in the trash, nothing is published and the write is rolled back, its documents skipped as changed during the run, as for an entity to create that another took the uuid of. A conversion without a job and a dry run, which is a job of its own, publish nothing. What remains is an item the job created unpublished that an editor means to keep unpublished, or unpublishes in place after the job published it, while the job still runs: the job publishes it, and the site unpublishes such items once the job is done.
Amended 2026-11-03. An item whose media type is under content moderation is not published this way either. Content moderation sets an item's status from its moderation state on every save, so the write that published it saved a draft and reported it published. MediaFinder asks for no such item to be published, where content moderation's information service says the item is moderated, and reports it as media_not_published, naming moderation; the item moves through its workflow.
An image becomes the item holding its file, where one does. MediaConversion::IMAGES, --images, is auto by default: an image whose file of the site an item holds, or one is created for, becomes a media block, and any other an image block, as before; image keeps every image an image block; media does as auto and reports each image that stays one, once, as media_not_created with the reason where an item was to be created and as media_not_found otherwise. The file is found and checked as for an image block, data-entity-uuid read only under editor_file_reference and a file outside the public files directory only where mayNameFile() allows it. The caption, from data-caption or a figure's figcaption, becomes the block's inline content with the formatting and links the format permits, rather than plain text, since the media block's caption is inline content; data-align and align its alignment; a width is not carried, since a view mode decides the size.
The alternative text of one use overrides the item's own, as 0036 stores it. Where the item's image field has alternative text enabled, an alt equal to the item's own is no override, other text is alt, an empty one, or "" on drupal-media, is decorative, and no attribute keeps the item's own; text longer than the 512 characters a block holds is cut there and reported. An item that takes no override gets none, as core's media embed ignores it.
An embedded item becomes a media block where the source format showed it. drupal-media is read only under the source format's media_embed, and drupal-entity under entity_embed, as 0041 reads data-entity-* only under the filter that read them; elsewhere a page showed nothing for the element, and it is dropped and reported, as is one naming no item by a uuid. drupal-entity is read as entity_embed's filter and builder read it: the item is the one data-entity-uuid names, or, only where the element names no uuid, the one data-entity-id names, which MediaReference::$id carries to the resolvers; an element naming neither a display nor a view mode, which the filter skips, is dropped; and an id no item has is reported and left out, since no block can name an item by its id. Its view mode is data-view-mode or the source filter's default one, or the view mode entity_embed's display names, the rendered entity display's where the element names none or the one called default, with data-entity-embed-display-settings or the older data-entity-embed-settings, or the element's data-view-mode where it names no display and no display settings, as the builder still reads it; a display that names none is the default view mode, reported. An item the site does not hold is named by its uuid, as the page named it, so that it shows once the item exists, and reported; an item of a type the format does not permit is kept as markup that could not be converted, and reported, since the save would refuse it.
A video's addresses are read without the network. RemoteVideoUrl reads YouTube's watch, embed, v, shorts and live addresses on its own hosts and the privacy-enhanced one, and youtu.be, and Vimeo's pages, channels, groups, albums, showcases and player, with an unlisted video's hash, read from h= or from the segment after the id in a page's address where it has the shape of every hash Vimeo shows, ten lower-case hexadecimal digits, since a segment of another shape, such as likes, is a page about the video, at no port but the scheme's own, and writes each as the page address core's provider list matches by its schemes, https://www.youtube.com/watch?v=<id> or https://vimeo.com/<id>[/<hash>]. A playlist or a channel is no video. An unlisted Vimeo video is known by its id and its hash together, since Vimeo shows it only with the hash, so an item holding the public address is never named for a player of the unlisted one, nor the other way round, and the uuid a created item derives from it differs. A player's settings are not carried; its start and end times, the playlist it played the video in and YouTube's privacy-enhanced host are reported, since the item shows the provider's own player, alone, from its start to its end. A site's rule hands the helper an address of another provider with the provider's name, which it vouches for.
An item is created only where the conversion asks, and only when its document is written. With MediaConversion::CREATE, --create-media, an image's file or a video no item holds gets one, in the first media type MediaConversion::MEDIA_TYPES, --media-type, names that holds it, core's image source for a file and an oEmbed source permitting the provider for a video, and only where the target format permits that type. It is owned by MediaConversion::OWNER, --media-owner, or else by the owner of the entity the markup belongs to, and is not created without one. It is never created for a file outside the public files directory: a new item would serve the file to whoever may view the item, where the old page served it to the host's readers. The item is in the host translation's language and holds the image's alternative text, empty where alt is empty, which marks the image decorative; where the type's image field requires alternative text, an image without an alt attribute gets no item, which would lack it, and stays an image block. It is published only where the page naming it is, for the default revision of a translation that is published or has no published status and for markup belonging to no entity, and unpublished for a draft or an unpublished translation, so that it shows no reader the page did not show it to. Amended 2026-11-01. A translation in the trash of the Trash module, which keeps its published status there, counts as unpublished, since its page shows no reader. An older revision's conversion, whose ConversionSource::$current is FALSE, creates nothing, since a page no longer shows it: createOnWrite() throws there, and the media rules fall back as without creation. The item is built but not saved: ConversionContext::createOnWrite() hands it to the conversion, and the bulk writer saves it in the transaction that writes the document naming it, after the revision's checks and before the revision. So a dry run, drush blocknote:convert-html, a refused document, a document left unchanged or skipped, and a write rolled back create nothing. ConversionGate leaves out of the result an item whose uuid no value of the finished document holds, and removes its entry from the report, since a block naming it may be dropped after the rule built it, as an image met in a caption, which a caption cannot hold, is dropped. The report says would_create, in a new category, created, keyed by the entity type and bundle; the write that creates the item turns it into created, and a write whose item another write of the run created removes it, as a found item is not reported. Where creation is asked and cannot happen, the reason is reported as media_not_created, keyed owner, private_file, type, type_not_permitted, type_fetches, older_revision, uuid_taken or alt_missing.
One image or video is one item. A created item's uuid is a name-based UUID of the media type and what it holds, the file's uuid or the video's provider and id, so every document naming the same image or video in one run names one item, a second run finds it by what it holds or by that uuid, and a run after the database is reset creates it with the same uuid; the writer saves an item only when no entity holds its uuid by then. An item holding the uuid is named only where it is of the planned type and its source field holds the same file or an address of the same video: one that took the uuid is not, and the element is converted as without creation and reported as media_not_created, keyed uuid_taken. createOnWrite() takes the check as a closure, which PendingEntity::isMatchedBy() runs at write time on an entity that took the uuid while the document was converted; where it says no, the write is rolled back and its documents skipped as changed during the run, rather than naming an item that holds something else. The save runs in a savepoint: one that meets the database's unique key on the uuid, because another process saved an entity with it between the writer's look and its save, is rolled back alone, and the entity that process saved is checked the same way. Amended 2026-11-01. An item holding the uuid that is in the trash of the Trash module is named neither, since no page shows it: the planning looks for the uuid with the trash ignored, as the database's unique key does, and reports such an item as media_not_created, keyed trashed, and the writer treats one it meets in the trash as taken.
A conversion never fetches anything from the network, creation included. A remote item is created with its address as its name, and a media type is used for creating remote items only where saving one asks the provider for nothing: it queues its thumbnail downloads, maps no metadata into a field but the name, and its source declares no attribute for the thumbnail's alternative text. Cron's queue then fetches the thumbnails, as for any item of such a type. Amended 2026-11-03. A queue of the submodule's own, blocknote_media_video_title, fetches a created remote item's title on cron the same way, through the item's oEmbed source, and renames the item where its name is still its address, so that a name someone gave it meanwhile is kept; hook_media_insert() queues an item whose revision log is the converter's and whose name is its address, in the transaction that saves it, so a write that is rolled back queues nothing. Both commands refuse another type as a usage error, and say how to change it; a conversion naming one in code reports media_not_created, keyed type_fetches. The addresses the module writes match their provider's schemes, so core never fetches one of them to discover a provider, when it renders the item or fetches its thumbnail. Amended 2026-11-02. A site whose remote video type fetches creates its video items first, outside any conversion, with drush blocknote:create-videos, which RemoteVideoCreator runs: it converts the values the conversion would convert, writing nothing and fetching nothing, with the media options and MediaConversion::FETCH, which lets MediaFinder::plan() plan an item in a type that fetches and leaves its name empty, and saves each planned item, which fetches the title and thumbnail through core's oEmbed resource fetcher as an author's save does. It re-converts the values rather than reading a dry run's report, since the report carries a video as a sentence and an excerpt of the markup, and none when the type fetches, and since planning is the conversion's, a video is read in every form and an item is found, owned, published and given its uuid as the conversion would. A video is asked for before its item is saved, through the fetcher that caches the answer for the save, so that a refusal is reported for its address and leaves no item, where core would save an item named by a placeholder; one failure never stops the next. An address an item holds is skipped, so running it again resumes. The conversion then finds the items by address and fetches nothing, as before.
Amended 2026-11-03. "A site's rule hands the helper an address of another provider with the provider's name, which it vouches for" overstated what a rule's word is worth: the item held whatever http or https address the rule passed, and core's thumbnail queue, and the title queue, fetched an address no scheme matched to discover a provider. MediaFinder::plan() now checks an address the module does not read itself against the URL schemes of the provider the rule names, in the oEmbed provider list read through core's provider repository, and creates nothing where none matches, reported as media_not_created, keyed address_not_provider. The title queue checks each item's address the same way before it fetches, and asks nothing where the first provider whose scheme matches, the one core's URL resolver finds, is not one the item's type allows. Reading the list may make core refresh its stored copy from the list's configured address once it is a week old, a request the content does not choose, so a conversion may make that one request; core's URL resolver is still not used to plan, since it fetches the address itself when no scheme matches.
Amended 2026-11-03. The title queue's amendment above overstated two things. It fetched through the item's oEmbed source, which turns every failure into no title, so a throttled or timed-out request dropped the item for good; the queue now fetches through core's URL resolver and resource fetcher, throws SuspendQueueException when the provider answers 429, which stops the queue for the cron run and leaves the item queued as it was, queues the item again with its count of tries after a timeout, a failed connection or a server error, up to three tries in all, and drops it after any other failure. And a write that is rolled back queues nothing only with the database queue, whose item is written in the write's transaction; another backend keeps the item, which then names an item that is not there.
A site's rule composes the public helper. blocknote_media.conversion, MediaConversion, offers blockForMedia(), blockForImage() and blockForRemoteUrl(), each returning a media block or NULL to fall back on, with overrides for the view mode, the alignment, the caption, the alternative text, the name and type of a created item and an address's provider, and caption() for a caption held in an attribute. Its constants are the options. A view mode the format does not permit is the default one, and reported.
Both commands gain the media options without depending on the submodule. The submodule's Drush command class declares --images, --create-media, --media-type, repeated for each type, and --media-owner on drush blocknote:convert and drush blocknote:convert-html through option hooks, and a validation hook checks them, a malformed one exiting with 2 like the base command's usage errors, as does a media type the format the documents are meant for does not permit, and passes them on as the rule options of 0039, only those that differ from the default, so that a job's identity holds them and a run with others is refused. The media types keep the order they were given in, once each, since an item is created in the first that holds it: a run naming them in another order is another job. A rule option naming one of them is refused, since each has an option of its own.
Alternatives considered¶
Creating an item while converting. Rejected: a document refused, skipped because its field holds another document, or left unwritten by a rehearsal would leave behind an item nothing names, and drush blocknote:convert-html, which stores nothing, would store items. Creating at write time costs a seam in the base module, createOnWrite(), which any rule may use for an entity a document needs.
Creating an item outside the write's transaction, as MediaStorage fetches outside the save's. Not needed: nothing is fetched, so the item is saved in the transaction and rolled back with the document.
Fetching each video's metadata while creating its item, as core does when an author adds one. Rejected: a site converting ten thousand embeds would send ten thousand requests in a burst, inside a maintenance window and under the host's row lock, a provider throttling them would leave items named media:<type>:<uuid> with a placeholder thumbnail and no word in the report, the dry run could not say what the run would do, and a run after a reset would name the items differently. A site that wants each video's title as its name sets one through the helper's name override, or renames items afterwards.
Recognizing a provider through core's URL resolver. Rejected: it fetches the provider list once its stored copy expires, and fetches the address itself when no scheme matches, which for an address from stored markup is a request the content chose. YouTube and Vimeo are read by the module; any other provider is named by a site's rule.
Creating an item for a private file. Rejected for the reason above: it would widen who may download the file. An existing item holding it is found and named.
A random uuid for a created item. Rejected: two documents converted in a dry run could not name the same item, and a run after a database reset would name other items than the rehearsal did.
Keeping a missing item's element as markup that could not be converted. Rejected: the page named the item, and a site migrating its media after its text sees it appear; the block renders nothing but its caption meanwhile, as core's embed showed an error in its place.
Converting every image into a media item by default. Rejected: creating items is permanent, so it waits for --create-media; finding an item that already holds the file is safe, so auto does it.
Overriding the alternative text of every image. Rejected: an override equal to the item's own text would stop following the item when its text is corrected.
A plugin type or an event for media lookups. Rejected for the reasons 0038 gives for rules.
Consequences¶
A site converting its media names the types to create items in, in the order they are to be tried, and ticks Queue thumbnail downloads on its remote video type for the run, and cron fetches the thumbnails afterwards, or, if its remote video type fetches, creates its video items first with drush blocknote:create-videos. A created video item is named by its address until someone renames it. Amended 2026-11-03. Cron's queue then names it by the video's title, unless someone renamed it first or the provider gives no title. An item created for a draft or an unpublished translation stays unpublished when its page is published, so the site publishes it with the page; amended 2026-11-03, a page its readers see that a conversion converts later publishes it while nobody has changed it; an image or a player whose file or video only an unpublished item holds is left as it was until the item is published and the document converted again with --overwrite. The older pass creates nothing, so an older revision's image no item holds stays an image block.
Amended 2026-11-03. Only a later page of the same job publishes such an item, as the amended decision says; an item an earlier job created stays unpublished, reported as media_not_published, until the site publishes it and converts the page again with --overwrite.
documents.csv has a created column, and the report a created category, which a dry run fills with would_create. Only a document written, or one a dry run would write, keeps its would_create entries: a refused or skipped document's are removed, since its write creates nothing, so that the column counts what a run creates. A document the converter wrote that converts the same again, and so would be left unchanged, is written again where it names an item to be created, which is one that was there when it was written and is gone, such as one deleted since: otherwise the document would name nothing until someone converted it with --overwrite.
An element the media rules leave alone is converted as before: an image without an item or creation stays an image block, and a player of another provider, or one no item holds without creation, is kept as markup that could not be converted. A site that converted before this wave converts again with --overwrite to replace the players it kept, as 0040 foresaw.
A media item's caption is converted with its formatting, where an image block's caption stays plain text. A rule that takes data-caption into a block's inline content removes the attribute, and FigureRule reads that as the figure's caption taken, as it reads a block's caption prop; a rule that does not would leave the figcaption as a paragraph too, which shows, where reading any block with content as the caption taken would lose a caption without a word.
In the synthetic rehearsal 0039 records, 959 players and tokens across 20,000 values named 142 remote video items, one for each video, 165 image items were created, and 141 thumbnail downloads were queued for cron. The conversion API stays experimental until the converter has run on a real site's content.
References¶
modules/blocknote_media/src/Convert/(MediaConversion,MediaFinder,MediaLookup,MediaReference,MediaResolverInterface,RemoteVideoUrl,DrupalMediaRule,ImageMediaRule,RemoteVideoRule),modules/blocknote_media/src/Drush/Commands/BlockNoteMediaConvertCommands.php,modules/blocknote_media/blocknote_media.services.yml.modules/blocknote_media/src/Convert/RemoteVideoCreator.php,modules/blocknote_media/src/Drush/Commands/BlockNoteMediaVideoCommands.php,tests/src/Kernel/Convert/RemoteVideoCreatorTest.php,CreateVideosCommandTest.php.modules/blocknote_media/src/Plugin/QueueWorker/VideoTitleFetcher.php,modules/blocknote_media/src/Hook/BlockNoteMediaHooks.php,tests/src/Kernel/Convert/VideoTitleQueueTest.php; core'sDrupal\media\Plugin\QueueWorker\ThumbnailDownloader.src/Convert/ConversionContext.php(createOnWrite()),src/Convert/PendingEntity.php,src/Convert/ConversionResult.php,src/Convert/ConversionGate.php,src/Convert/Bulk/DocumentWriter.php,src/Convert/Report/ReportCategory.php,src/Convert/Report/ConversionReport.php(replace()),src/Convert/Report/CsvReportWriter.php,src/Convert/Rule/FigureRule.php,src/Convert/HtmlWalker.php.tests/src/Unit/Convert/RemoteVideoUrlTest.php,tests/src/Kernel/Convert/MediaConversionTest.php,MediaBulkConversionTest.php,MediaConvertOptionsTest.php,MediaGoldenConversionTest.php,tests/fixtures/conversion/media/,tests/modules/blocknote_media_convert_test,modules/blocknote_media/js/__tests__/conversion-roundtrip.test.ts.- Drupal core 11.4:
Drupal\media\Entity\Media::prepareSave(),MediaStorage::save(),Drupal\media\OEmbed\UrlResolver::getProviderByUrl(),ProviderRepository::getAll(),Drupal\media\Plugin\Filter\MediaEmbed::process()andapplyPerEmbedMediaOverrides(),Drupal\media\Plugin\media\Source\OEmbed, themedia_test_oembedmodule andcore/modules/media/tests/fixtures/oembed/providers.json. entity_embed: theentity_embedfilter anddata-entity-embed-display. - docs/converting.md section 3, docs/extending.md section 14.
- 0006, 0015, 0018, 0036, 0038, 0039, 0040, 0041.