<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bozhidar Valchev</title>
    <description>The latest articles on DEV Community by Bozhidar Valchev (@bozhidar_valchev).</description>
    <link>https://dev.to/bozhidar_valchev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3591741%2F5c8daa0d-71cb-4ee9-b792-803755d72c13.jpg</url>
      <title>DEV Community: Bozhidar Valchev</title>
      <link>https://dev.to/bozhidar_valchev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bozhidar_valchev"/>
    <language>en</language>
    <item>
      <title>Manual WordPress Migration, Step by Step</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:05:24 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/manual-wordpress-migration-step-by-step-pnl</link>
      <guid>https://dev.to/bozhidar_valchev/manual-wordpress-migration-step-by-step-pnl</guid>
      <description>&lt;p&gt;This WordPress migration guide moves a site from one host to another by hand. Files go over SFTP, the database through phpMyAdmin or WP-CLI, and DNS comes last. Every step has two versions. The first works with nothing more than a control panel and an SFTP client. The second is for readers who have SSH and want the job done in a few commands.&lt;/p&gt;

&lt;p&gt;I first wrote this in 2015 on my old blog. Some advice from that version did not age well. One tip in it could even break a site without any warning, so this is a rewrite rather than a repost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61r6yviwgvk82zuxo193.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61r6yviwgvk82zuxo193.png" alt="WordPress migration flow: files and database from the old host to the new one, then DNS and SSL" width="800" height="442"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Numbers match the steps below.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  When a manual WordPress migration makes sense
&lt;/h2&gt;

&lt;p&gt;Migration plugins such as Duplicator, All-in-One WP Migration and Migrate Guru work well for most small sites. If one of them does the job for you, use it. Doing it by hand pays off when a site outgrows the plugin’s free tier or the new host’s upload limit. It also helps when a plugin migration has already failed and you need to see which step broke.&lt;/p&gt;

&lt;p&gt;It also teaches you what a WordPress migration actually is. Every plugin does the same steps below, only out of sight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SFTP access to both hosts, and phpMyAdmin or SSH on both. Plain FTP sends your password in clear text, so use SFTP wherever the host offers it.&lt;/li&gt;
&lt;li&gt;An SFTP client. &lt;a href="https://filezilla-project.org/" rel="noopener noreferrer"&gt;FileZilla&lt;/a&gt; is fine; download it from the official site only. For SSH, Windows 10 and 11 already ship with OpenSSH, so &lt;code&gt;ssh&lt;/code&gt; works from any terminal without PuTTY.&lt;/li&gt;
&lt;li&gt;The site added as a domain or account on the new host. You also need an empty database, a database user and its password. If you are not sure how, ask the new host’s support.&lt;/li&gt;
&lt;li&gt;The PHP version of the old site. In wp-admin it is under Tools &amp;gt; Site Health &amp;gt; Info &amp;gt; Server. Set the new host to the same version or a newer one that your theme and plugins support.&lt;/li&gt;
&lt;li&gt;A lower DNS TTL. A day before the move, set the TTL of the domain’s A record to 300 seconds. The switch in step 9 then spreads within minutes instead of hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plan a content freeze as well. Comments, orders and form entries that reach the old site after the export never make it to the new one. For a blog that means a quiet hour. For a WooCommerce shop it means maintenance mode.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Export the database from the old host
&lt;/h2&gt;

&lt;p&gt;Without SSH, open phpMyAdmin and select the site’s database. Click Export, keep the Quick method and the SQL format, and click Go. If the database is large, choose the Custom method and set compression to gzip. phpMyAdmin imports &lt;code&gt;.sql.gz&lt;/code&gt; files directly, and the compressed file is often a tenth of the size.&lt;/p&gt;

&lt;p&gt;With SSH, go to the WordPress directory and let WP-CLI read the credentials from &lt;code&gt;wp-config.php&lt;/code&gt; for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/public_html
wp db &lt;span class="nb"&gt;export&lt;/span&gt; ~/site.sql
&lt;span class="nb"&gt;gzip&lt;/span&gt; ~/site.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this file even after the migration succeeds. It is your backup of the old site.&lt;/p&gt;

&lt;p&gt;While you are in the database, note the table prefix. The tables are named something like &lt;code&gt;wp_options&lt;/code&gt; and &lt;code&gt;wp_posts&lt;/code&gt;, and the part before &lt;code&gt;options&lt;/code&gt; is the prefix. You will need it in step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Copy the files
&lt;/h2&gt;

&lt;p&gt;Without SSH, connect to the old host with FileZilla and download the whole WordPress directory. First turn on Server &amp;gt; Force showing hidden files. Otherwise &lt;code&gt;.htaccess&lt;/code&gt; stays behind, and with it your redirects and any custom rules. Then connect to the new host and upload everything into the site’s directory. Press F5 if new files do not appear in the listing.&lt;/p&gt;

&lt;p&gt;Thousands of small files transfer slowly over SFTP. With a File Manager in cPanel or Plesk on both hosts, you can pack the directory into one archive. Move that single file and extract it on the new host.&lt;/p&gt;

&lt;p&gt;With SSH, you can stream the files from one server to the other through your own computer. No archive lands on either disk, and the two servers never need access to each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@old-host &lt;span class="s2"&gt;"tar czf - -C ~/public_html ."&lt;/span&gt; | ssh user@new-host &lt;span class="s2"&gt;"tar xzf - -C ~/public_html"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the full installation, core files included, rather than a fresh WordPress download plus &lt;code&gt;wp-content&lt;/code&gt;. Both approaches work. The full copy has one less place for a version mismatch to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Edit wp-config.php on the new host
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;wp-config.php&lt;/code&gt; in the new site’s directory and fill in the database you created on the new host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_name'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_USER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_user'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_password'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'localhost'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;DB_HOST&lt;/code&gt; with the new host instead of assuming &lt;code&gt;localhost&lt;/code&gt;. Managed hosting and cloud databases often use a separate hostname. A wrong value gives you “Error establishing a database connection” and nothing else.&lt;/p&gt;

&lt;p&gt;Further down the file, confirm that &lt;code&gt;$table_prefix&lt;/code&gt; matches the prefix you noted in step 1. If it does not, WordPress finds no tables with that prefix and offers to install itself from scratch. Also look for absolute paths the old host needed, for example in &lt;code&gt;WP_CONTENT_DIR&lt;/code&gt; or a cache plugin’s constant. Change them to the new server’s paths.&lt;/p&gt;

&lt;p&gt;With SSH, WP-CLI edits the file for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_NAME new_database_name
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_USER new_database_user
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_PASSWORD &lt;span class="s1"&gt;'new_database_password'&lt;/span&gt;
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_HOST localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Import the database
&lt;/h2&gt;

&lt;p&gt;Without SSH, open phpMyAdmin on the new host and select the empty database. Click Import, choose the file from step 1 and click Go. If phpMyAdmin rejects it as too large, compress it with gzip first. If it is still over the limit, ask the host’s support to import it for you. Most will do it within the hour.&lt;/p&gt;

&lt;p&gt;With SSH, upload the file and import it from the WordPress directory. WP-CLI takes the credentials from the &lt;code&gt;wp-config.php&lt;/code&gt; you just edited, which is why step 3 comes first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/public_html
&lt;span class="nb"&gt;gunzip&lt;/span&gt; ~/site.sql.gz
wp db import ~/site.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One error comes up often when the old host runs MySQL 8 and the new one runs MariaDB: &lt;code&gt;Unknown collation: 'utf8mb4_0900_ai_ci'&lt;/code&gt;. MariaDB does not know that collation. Open the &lt;code&gt;.sql&lt;/code&gt; file in a text editor, replace every &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt; with &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, and import again. This replacement is safe, because it touches table definitions and not your content.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Replace the old domain and paths
&lt;/h2&gt;

&lt;p&gt;Skip this step if the domain stays the same and nothing in the database points to the old server’s directories. Otherwise, this is the step where most manual WordPress migrations go wrong.&lt;/p&gt;

&lt;p&gt;The 2015 version of this guide told you to open the SQL file in Notepad++ and replace the domain there. Do not do that. WordPress and its plugins store widgets, theme options and plugin settings as serialized PHP. In that format the length of every string is written next to it, for example &lt;code&gt;s:22:"http://old-domain.com/"&lt;/code&gt;. Change the domain to one of a different length and the stored length no longer matches. WordPress cannot read the value and silently drops it, so widgets, menus and theme settings disappear with no error message.&lt;/p&gt;

&lt;p&gt;With SSH, WP-CLI understands serialized data. Run it with &lt;code&gt;--dry-run&lt;/code&gt; first, which only reports how many replacements it would make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp search-replace &lt;span class="s1"&gt;'https://old-domain.com'&lt;/span&gt; &lt;span class="s1"&gt;'https://new-domain.com'&lt;/span&gt; &lt;span class="nt"&gt;--all-tables&lt;/span&gt; &lt;span class="nt"&gt;--skip-columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;guid &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
wp search-replace &lt;span class="s1"&gt;'https://old-domain.com'&lt;/span&gt; &lt;span class="s1"&gt;'https://new-domain.com'&lt;/span&gt; &lt;span class="nt"&gt;--all-tables&lt;/span&gt; &lt;span class="nt"&gt;--skip-columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;guid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same command fixes old server paths, such as &lt;code&gt;/home2/olduser/public_html&lt;/code&gt; replaced with the new site’s directory. Some plugins store those for caches, logs and uploads. After a move they point to a directory that no longer exists.&lt;/p&gt;

&lt;p&gt;Without SSH, use &lt;a href="https://github.com/interconnectit/Search-Replace-DB" rel="noopener noreferrer"&gt;Search-Replace-DB&lt;/a&gt; by interconnect/it. Upload its folder to the site’s root under a random name that nobody will guess. Open it in the browser, run a dry run, then the real replacement. Delete the folder right after. Left on the server, it gives anyone who finds it full write access to your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Test the new site before DNS changes
&lt;/h2&gt;

&lt;p&gt;The domain still points to the old host, but your own computer can be told otherwise. Add a line to your hosts file with the new server’s IP address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;203.0.113.10 example.com www.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows the file is &lt;code&gt;C:\Windows\System32\drivers\etc\hosts&lt;/code&gt; and needs an editor started as administrator. On macOS and Linux it is &lt;code&gt;/etc/hosts&lt;/code&gt;. Restart the browser and click through the site: the home page, a few posts, the contact form and the wp-admin login. The new host probably has no SSL certificate for the domain yet, so expect a certificate warning at this stage. Everything else should work.&lt;/p&gt;

&lt;p&gt;Remove the line when you are done. Otherwise you will never notice if DNS itself is wrong, because your computer keeps going straight to the new server.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Fix file permissions
&lt;/h2&gt;

&lt;p&gt;Files copied from another server sometimes arrive with the wrong permissions. WordPress expects 755 for directories and 644 for files. With SSH, run this in the WordPress directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;755 &lt;span class="o"&gt;{}&lt;/span&gt; +
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;span class="nb"&gt;chmod &lt;/span&gt;640 wp-config.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;+&lt;/code&gt; at the end passes many files to one &lt;code&gt;chmod&lt;/code&gt; call. That is much faster than one call per file. &lt;code&gt;wp-config.php&lt;/code&gt; holds the database password, so it gets tighter permissions. If the site shows a white page after the last command, PHP runs under a different group on that host. Try 600, or ask support which value they recommend.&lt;/p&gt;

&lt;p&gt;Without SSH, FileZilla can do the same. Right click the WordPress directory, choose File permissions, enter 755, tick Recurse into subdirectories and select Apply to directories only. Repeat with 644 and Apply to files only.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Flush permalinks and caches
&lt;/h2&gt;

&lt;p&gt;In wp-admin, open Settings &amp;gt; Permalinks and click Save Changes without changing anything. This rebuilds the rewrite rules and fixes most “404 on every page except the home page” problems after a move. Then clear the cache of any caching plugin and of the CDN, if you use one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp rewrite flush
wp cache flush
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Switch DNS
&lt;/h2&gt;

&lt;p&gt;At your DNS provider, point the A record of the domain and of &lt;code&gt;www&lt;/code&gt; to the new server’s IP. Do the same for the AAAA record if you have one. Leave the MX records alone unless your email is moving as well. It is easy to break email with a migration that was only meant to move a website.&lt;/p&gt;

&lt;p&gt;With the TTL lowered a day earlier, most visitors reach the new server within minutes. Some resolvers ignore TTL, so keep the old hosting account running for at least a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Issue the SSL certificate
&lt;/h2&gt;

&lt;p&gt;Let’s Encrypt checks that the domain points to the server asking for the certificate. This step therefore waits until DNS has switched. Most control panels issue it with one click, often automatically. If the site then shows a padlock with a warning, some content still loads over &lt;code&gt;http://&lt;/code&gt;. Run the search-replace from step 5 again, from &lt;code&gt;http://example.com&lt;/code&gt; to &lt;code&gt;https://example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the browser shows “Your connection is not private” after the switch, my guide on &lt;a href="https://valcheff.net/fix-ssl-your-connection-is-not-private/" rel="noopener noreferrer"&gt;fixing that SSL error&lt;/a&gt; walks through the usual causes.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Check the error log
&lt;/h2&gt;

&lt;p&gt;Every host is configured a little differently. A plugin that ran quietly on the old server may complain on the new one. The host’s PHP error log is usually in the control panel. For WordPress’s own log, add these lines to &lt;code&gt;wp-config.php&lt;/code&gt; for a day or two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG_LOG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG_DISPLAY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors then go to &lt;code&gt;wp-content/debug.log&lt;/code&gt; without showing on the page. Set &lt;code&gt;WP_DEBUG&lt;/code&gt; back to false when you are done. On many servers the log file is readable from the web. If you cannot make sense of an error, send it to the new host’s support. That is part of what you pay them for.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the move
&lt;/h2&gt;

&lt;p&gt;Keep the old hosting and the database dump for at least a week. Watch the error log and check that contact forms still send email. Also open the site from a phone on mobile data, which uses a different DNS resolver than your home network. Cancel the old account only when a week has passed without surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about letting an AI agent do it?
&lt;/h2&gt;

&lt;p&gt;I tried. For this rewrite I wrote a prompt for a coding agent with shell access. I ran it with Claude Code against two throwaway WordPress servers in Docker. The agent did the first half well. It took an inventory of both hosts and noticed the non-default table prefix. After a backup of the database and the files, it streamed the files across. It filled in &lt;code&gt;wp-config.php&lt;/code&gt; without ever printing a password, and it stopped for approval where the prompt told it to.&lt;/p&gt;

&lt;p&gt;Then it reached the database import. Claude Code’s own safety layer refused to run it, even after I had approved the step. Overwriting a database on a remote server is exactly what these tools leave to a person. I think that is the right call. It does mean the riskiest step of the whole migration stays with you. An agent can save you time on the preparation and on checking the result afterwards. Plan to run steps 4 and 5 yourself.&lt;/p&gt;

&lt;p&gt;If this still feels like too much, hiring someone to do it is a perfectly reasonable choice. A broken migration usually costs more than a paid one.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/manual-wordpress-migration/" rel="noopener noreferrer"&gt;Manual WordPress Migration, Step by Step&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>linux</category>
      <category>mariadb</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>In Search of the Best A.I. Literary Translator – Detailed</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:08:52 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-detailed-30pk</link>
      <guid>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-detailed-30pk</guid>
      <description>&lt;p&gt;My wife reads faster than books get translated into Bulgarian. Some of the ones she wants don’t exist in Bulgarian and probably never will, because the market is small and the translation doesn’t pay for itself.&lt;/p&gt;

&lt;p&gt;So I tried machine translation. It came out unreadable: sentences that are grammatically correct and mean nothing, and dialogue punctuated the English way. My wife got to page thirty and gave up.&lt;/p&gt;

&lt;p&gt;I started with &lt;a href="https://www.deepl.com" rel="noopener noreferrer"&gt;DeepL&lt;/a&gt;, which everyone calls the best. It turned out to be all talk, and later in the test it came last of ten.&lt;/p&gt;

&lt;p&gt;The same happened with qwen3.8-max. I only included it because it is supposedly the best model for translation. It came second to last and was the only one that dropped parts of the text. All talk again.&lt;/p&gt;

&lt;p&gt;So I decided to run my own study and find out which AI literary translator really does the job best. In Bulgarian specifically, because a model that translates beautifully into Spanish isn’t necessarily good at Bulgarian, and the English-language rankings say nothing about it. Bulgarian has things English doesn’t. Gender follows the word the translator chose, so the same ship is correctly “she” if you called it a galley and “he” if you later called it a ship. The definite article sticks to the end of the word, there is a separate mood for retelling what you didn’t witness, and dialogue opens with a dash. At every one of those points the models differed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted to find out
&lt;/h2&gt;

&lt;p&gt;Which model, with what processing around it, can translate a work of fiction into Bulgarian well enough that someone finishes the book, and what that costs. I also wanted to know whether it is possible to measure at all who translates better.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested the AI translators
&lt;/h2&gt;

&lt;p&gt;I took Robert E. Howard’s story “Shadows in the Moonlight” from 1934. Howard died in 1936, so in the EU the text has been free since 2007. The story is 296 paragraphs and 12,079 words. As a yardstick I used an old published human translation: 305 paragraphs and 12,276 words, with every paragraph of the original matched to one in it.&lt;/p&gt;

&lt;p&gt;The translators were eight language models: gpt-6-astra, claude-opus-5, gemini-3.1-pro, gemini-3.8-flash, deepseek-v4-pro, grok-4.6, kimi-k3 and qwen3.8-max. Next to them I put DeepL, which is not a language model and serves as the floor. The models ran through &lt;a href="https://github.com/BigDawnGhost/wenyi" rel="noopener noreferrer"&gt;wenyi&lt;/a&gt;, a tool for translating books, to which I added a Bulgarian profile. Every model got byte for byte the same task, and between any two setups only four lines differ: a comment, the model, the provider and the file path. In the first round they all translated with no extra steps, in chunks of 1,800 tokens.&lt;/p&gt;

&lt;p&gt;I scored them three ways. First, mechanical checks over the whole text. Then a full read of all 296 paragraphs of every translation against the original, by the same reader, Fable 5.1, with serious, moderate and minor errors defined in advance. And finally a blind comparison: six passages from the ten translations, unlabelled and shuffled, in front of four judges from four different makers, Fable 5.1, Astra Pro, Gemini Pro and DeepSeek. Each of them has a relative among the translators, which is why there were four.&lt;/p&gt;

&lt;p&gt;There were a few rules. The judges don’t know who translated what. They score all the translations at once, so they measure on the same scale. Their instructions also explain how gender works in Bulgarian, because without that almost every translation looked wrong. And I checked whether the models had seen the published translation during training.&lt;/p&gt;

&lt;p&gt;The whole bill for model calls is about 46 dollars: 42.53 through OpenRouter and about 3.50 on DeepSeek’s own account.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Whether a model works depends on who answers the call
&lt;/h3&gt;

&lt;p&gt;Most models are bought through a middleman like &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;, and behind one model name a different company can answer each call. For DeepSeek the catalogue lists 16 such companies, for Kimi 20. The price changes with whichever answers: one and the same probe cost 0.0227 dollars by the catalogue, and 0.01151 was charged. Behaviour changes more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm42mwq4ol42nkaqc3kjc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm42mwq4ol42nkaqc3kjc.png" alt="The same model, two serving paths" width="799" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Through one of the providers, deepseek-v4-pro cut the translation off at every ceiling I tried: 6,000, 16,000 and 48,000 tokens. Directly through DeepSeek’s own API it translated the whole story without a single break. gemini-3.8-flash through Google AI Studio returned 19 empty responses and stopped at 20% of the book, while through Google Vertex it went all the way. My first thought was censorship, because blood gets spilled in the story. I repeated the same call nine times and the empty response never came back. It was a temporary fault at the provider.&lt;/p&gt;

&lt;p&gt;The empty response also exposed a bug in wenyi itself, which crashed on it. The fix is three lines and all 790 tests pass. One of the failures was mine: out of habit I set a ceiling of 32,000 tokens on DeepSeek, and the translation stopped exactly there. Since then I pin the provider explicitly and check it with a live call before I start the big book.&lt;/p&gt;

&lt;h3&gt;
  
  
  Counting can’t tell a good translation from a bad one
&lt;/h3&gt;

&lt;p&gt;The mechanical checks count words, dialogue lines, dashes, quotation marks, very short paragraphs, and missing or repeated paragraphs. One solid thing came out of it: none of the nine dropped a whole paragraph. After that, counting stops helping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiszqg1ujd21j05ckl7b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiszqg1ujd21j05ckl7b.png" alt="Identical on the numbers, four tiers apart on reading" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six of the models come out practically identical: length between 0.948 and 0.975 of the original and 150 or 151 dialogue lines with a dash. kimi-k3 has excellent numbers and a text full of words that don’t exist in Bulgarian, and in the blind comparison it is among the last. Reading split those same six models into four clearly different levels. Counting only catches the obvious failures.&lt;/p&gt;

&lt;p&gt;The full read produced these findings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Translation&lt;/th&gt;
&lt;th&gt;Findings, count&lt;/th&gt;
&lt;th&gt;Severe, count&lt;/th&gt;
&lt;th&gt;Moderate, count&lt;/th&gt;
&lt;th&gt;Minor, count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-6-astra&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grok-4.6&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.8-flash&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deepseek-v4-pro&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human translation&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kimi-k3&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.8-max&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepL&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The totals aren’t quite comparable, because each reader decides for itself what to note. The serious column is comparable, because serious was defined the same way for everyone. DeepL is last with 16, and almost all of them are words with two meanings. “Quarter!”, which in a fight is a plea for mercy, becomes “Четвърт!”, the fraction. “Painter”, the rope on a boat, becomes “Художникът”, the artist. In one paragraph the word is wrong, in the next it is right, because every sentence is translated on its own. qwen3.8-max drops text and the reader feels the gap. DeepL writes smooth Bulgarian that says something else, and an editor without the original in front of them won’t catch it. According to the reader, bringing the DeepL translation up to print costs 60 to 70% of a new translation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which AI literary translator came out best in the first round
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" alt="Round one: ten candidates, three judges" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One judge gave only an order without points, so the chart shows three. The first three places are the same for every judge: gpt-6-astra, gemini-3.8-flash and deepseek-v4-pro. Further down, opinions differ by up to three places.&lt;/p&gt;

&lt;p&gt;I also checked how much each judge goes easy on its relative. Fable 5.1 doesn’t move claude-opus-5 at all, Astra Pro lifts gpt-6-astra by 0.33 places, DeepSeek lifts its own model by one place, and Gemini Pro lifts gemini-3.8-flash by 1.33 places and gives it 10 out of 10 on all six criteria, the only perfect score in the first round. The bias is real but small: if I remove each judge from the scoring of its own family, the top doesn’t move.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth7foww7ye2uah0xcjve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth7foww7ye2uah0xcjve.png" alt="What one 12,000-word story costs to translate" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expensive and slow doesn’t buy proportional quality. gemini-3.8-flash is the cheapest and the fastest, 33 cents and seven and a half minutes for the story, and it is second in the ranking. gpt-6-astra costs 4.20 dollars, 12.7 times more, for a difference the judges describe as one level. grok-4.6 works 10.5 times longer than flash and comes in sixth or seventh.&lt;/p&gt;

&lt;p&gt;There is also a hidden bill. Some models think out loud before they answer, and that thinking is billed as output text, even though nobody reads it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5w11d1nwxduvtiyjrv4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5w11d1nwxduvtiyjrv4l.png" alt="Output tokens billed per translation" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Bulgarian translation itself is about 65,000 tokens. grok-4.6 returned 284,459, deepseek-v4-pro 300,398, and claude-opus-5 managed with 41,254. The setting for how much a model should think doesn’t mean the same thing at different makers: at the lowest level three models don’t think at all, while two spend about 6,000 tokens each and return a broken answer. The only reliable way to budget is to translate one chapter and multiply.&lt;/p&gt;

&lt;h3&gt;
  
  
  What wenyi’s pipeline adds
&lt;/h3&gt;

&lt;p&gt;wenyi can do more than a plain translation. Before translating, the model reads the whole book and makes a summary and a glossary of names. During translation it polishes the text. After translation it reviews its own work and fixes what it finds. I tried these steps on the four best models from the first round. The other four stopped here, because the same bill on a model four levels down answers nothing new.&lt;/p&gt;

&lt;h4&gt;
  
  
  The review on its own
&lt;/h4&gt;

&lt;p&gt;First I ran only the review, on the finished translation. That way the translation stays word for word the same, and everything that changes afterwards is the review’s doing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhblk0m7y3g0dti9tyadl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhblk0m7y3g0dti9tyadl.png" alt="What the review pass actually moves" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nobody rewrote the text. deepseek-v4-pro changed the most, 11 paragraphs of 296, and found three real errors in its own translation, among them a line where the man had been put in the feminine. gpt-6-astra changed two paragraphs and got both right, and its review cost 3.07 dollars, three quarters of the price of the whole translation. gemini-3.1-pro changed eight and made one worse: of “В името на Ищар!”, “In Ishtar’s name!”, it left only “Ищар!”. That is the only one of the 22 changes that makes the text worse.&lt;/p&gt;

&lt;p&gt;One and the same error, “педя” (a span) instead of “длан” (a palm), appears in six of the eight translations. gpt-6-astra’s review found it. The reviews of gemini-3.1-pro and deepseek-v4-pro went over the same error and said nothing. Same pipeline, different result, so the difference is in the reviewer. When the three versions are scored, the review mainly lifts the errors criterion, by one point in three of the four models, while language and completeness don’t move.&lt;/p&gt;

&lt;p&gt;In the whole material there was one single serious error, the kind that turns the meaning upside down. At gemini-3.8-flash, “too drunk to vote either way” had become “напи се до козирката, за да може изобщо да гласува”, drunk so that he could vote at all. flash’s review changed exactly one paragraph of 296, and it was that one.&lt;/p&gt;

&lt;h4&gt;
  
  
  The full pipeline
&lt;/h4&gt;

&lt;p&gt;Then I ran the four models again, this time through the full pipeline. In the chart each of them has three versions: the plain translation, the same translation with a review added, and a new translation through the full pipeline. Each set of three was scored separately, by one blind judge who sees it all at once, so compare the points only within one model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5exn1arzhtlhqhmjlkho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5exn1arzhtlhqhmjlkho.png" alt="What the pipeline does to the score" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gpt-6-astra goes up from 6 to 8 points, gemini-3.1-pro from 7 to 8. gemini-3.8-flash drops from 8 to 7, deepseek-v4-pro from 7 to 6. For both drops the judges say the same thing: the translation becomes more precise in detail and in gender, but pays for it with literalisms and words that don’t exist. So there is no general answer to whether you should switch the pipeline on. It depends on the model, and the only way to find out is to try both. It costs 1.9 times the plain translation for gemini-3.1-pro and 3.4 times for flash.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0mh44pv3gvaf0268xx8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0mh44pv3gvaf0268xx8.png" alt="Same texts, counted by severity" width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you just add up the findings, the pipeline looks harmful: 90, then 93, then 106. The breakdown shows the opposite. Moderate errors fall from 29 to 19, while minor remarks rise from 60 to 87, and that holds for each of the four models on its own. The pipeline trades errors of meaning for style remarks. Four out of four in the same direction happens by chance once in sixteen times, so it is a signal, but not yet proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  The human translation came in below the best machines
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" alt="The human translation, criterion by criterion" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the different scorings the published translation sits between sixth and eighth place out of ten. Its biggest loss is completeness, 4 out of 10, the lowest score after qwen3.8-max, which really does drop text. The translator condenses on purpose. Of 125 departures from the original, 66 are deliberate choices, 35 are errors of meaning and 23 are language or typography. The criterion, though, counts every cut as a loss, even when it works. The human translation has both errors and style, and this scale only sees the errors.&lt;/p&gt;

&lt;p&gt;Completeness isn’t the only reason. For Bulgarian the translation gets 6 out of 10, level with claude-opus-5 and below four of the machines: gpt-6-astra gets 9, gemini-3.8-flash gets 8. It keeps using the short definite article where Bulgarian grammar needs the full one. There is wrong verb government too, one case of it twice. About ten typesetting leftovers remain, such as a line-break hyphen in the middle of a word, doubled words and Latin letters inside Cyrillic words. On gender it scores 8 out of 10, and the review finds one such slip: once Olivia turns masculine. An editor or a proofreader should have caught most of this. The reader still reads what was printed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The models hadn’t seen the translation before
&lt;/h3&gt;

&lt;p&gt;The published translation may have ended up in the data the models were trained on. Then they would simply be remembering it. I checked two ways. First, the longest common run of words: between a model and the human translation it averages 5.2 to 5.7 words, and between the models themselves 8.8. Then I gave the models a passage of the human translation and asked them to continue: the continuation resembles the human as much as it resembles their own translation. Nothing stands out. The stronger check, the same test on a book with no published Bulgarian translation, I skipped on purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l0tvluygnilbq9e608y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l0tvluygnilbq9e608y.png" alt="Longest wording a candidate shares with another text" width="799" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The second round: the judge sits down to translate
&lt;/h3&gt;

&lt;p&gt;After the first round, Fable 5.1 did all the reading and scoring. For two days it checked other people’s translations without handing in one of its own. So I had it translate the story too, with wenyi’s same instructions, run by hand. That is possible because wenyi’s instructions don’t depend on the model. I split the book into the same chunks of 1,800 tokens and got 10, exactly as many requests as gpt-6-astra made through the tool. It made the three versions the others got: a plain translation, one with a review, and one through the full pipeline.&lt;/p&gt;

&lt;p&gt;Fable couldn’t judge its own translation, so two outside judges scored it, gemini-3.8-flash and deepseek-v4-pro. And all the translations came back into play. The ten from the first round plus Fable’s three, thirteen texts at once in front of each judge, on the same six passages. It doesn’t work any other way: an eight given without knowing there is a nine in the same room isn’t the same eight.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq0tpmqnap04x4g8tjnc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq0tpmqnap04x4g8tjnc.png" alt="Round two: thirteen candidates, two outside judges" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gpt-6-astra stays first. Fable is right behind it. But I don’t count it as a win, for four reasons, all in its favour. It knew what it would be judged on, because its rules were written after the first round and answer exactly the errors the others were penalised for. After every chunk, it checked its own work. And it had the whole book in front of it, while the others see a little at a time. And once it caught and fixed its own error by itself, a Latin “p” inside a Bulgarian word. This round shows what a well-tuned process adds. Which model translates better is a different question.&lt;/p&gt;

&lt;p&gt;Fable’s review of its own translation found nothing, so the reviewed version is byte for byte the same as the plain one. That is how two identical texts ended up in the blind comparison without anyone planning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about measuring itself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The same reader doesn’t repeat its findings
&lt;/h3&gt;

&lt;p&gt;The translations with a review added differ from the plain ones by a few paragraphs, and the same reader read both. In practice that is the same text read twice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0esd3tznunh3f1x1h1e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0esd3tznunh3f1x1h1e.png" alt="One reviewer, the same text, read twice" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only 68% of the paragraphs with a remark on the first read have a remark on the second. On average 14 findings per text appear or vanish in paragraphs the review never touched. So on 296 paragraphs the finding count has an error of about 10 to 15. The difference between 15 and 22 findings doesn’t exist. The difference between 15 and 92 does.&lt;/p&gt;

&lt;p&gt;The second round gave a cleaner experiment. Fable’s two identical texts went into the blind comparison under different letters. The judges saw them as two different candidates and gave them identical scores on all six criteria. So the disagreement comes from splitting the reading into several sittings, and when a judge sees everything at once, it is precise. Hence the rule: score everything in one call.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I almost published an error that doesn’t exist
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwshemt0ufmkddbmddenm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwshemt0ufmkddbmddenm.png" alt="What one rule in the instruction was worth" width="799" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first scoring instruction checked gender the way an English speaker would. In English the ship and the galley are both “it”, so a translation that says “ще го огледаме” (masculine) about the ship and “ще я потопя” (feminine) about the galley looked confused. By that rule almost every translation had a gender error, and for a while I had it written down as a finding. There is no such error. “Галера” is feminine, “кораб” is masculine, so both are right. Seven of the ten readers noticed it on their own, and that is how I noticed it too. After I rewrote the rule, “almost all” shrank to one real gender error in the blind comparison. The instruction has to carry the rules of the language even when most readers know them, because the rest will fill the table with errors that aren’t there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden bill
&lt;/h3&gt;

&lt;p&gt;The dollars cover only the calls to the models. All the reading, the scoring, Fable’s translations and this article ran on a monthly subscription, which isn’t paid per call and has its own ceiling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedib8hour90dw0a2sl3b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedib8hour90dw0a2sl3b.png" alt="The bill that never reaches an invoice" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recorded usage is over 3 million tokens across 19 tasks. One read of the story against the original is about 137,000 tokens, one translation about 190,000. The subscription ceiling isn’t shown in tokens, only in percentages. I hit it twice, and twice the work stopped halfway. Nothing was lost, once because the files had been saved in time and once because the task could be resumed. So now every task saves its result as soon as it has one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this study doesn’t prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything rests on one story of 12,079 words. The next step is a longer book with the top three models.&lt;/li&gt;
&lt;li&gt;Most numbers after the first round come from a single reader, and that reader repeats only 68% of itself.&lt;/li&gt;
&lt;li&gt;The pipeline result is four out of four in the same direction. It is a signal until a second book says otherwise.&lt;/li&gt;
&lt;li&gt;Thirteen candidates on a ten-point scale squash the top. At one judge three of them share the ten.&lt;/li&gt;
&lt;li&gt;The stronger check of whether the models know the human translation, with a book that has no published translation, hasn’t been done yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So which model translates fiction best
&lt;/h2&gt;

&lt;p&gt;Overall, gpt-6-astra showed the best results in every round, but at a price. It is the most expensive model, more than 12 times the price of the cheapest one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9rbvuw9nksvho7ek6ne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9rbvuw9nksvho7ek6ne.png" alt="AI literary translator ranking: points and price" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gemini-3.8-flash gave the best value for money: it works fast, cheap and well enough.&lt;/p&gt;

&lt;p&gt;I also saw that using a pipeline during translation improves the results significantly, and if I set out to translate fiction myself, I would definitely do it with something like wenyi, even if only written up as a skill or a custom agent in the tool I use.&lt;/p&gt;

&lt;p&gt;In the end I did exactly that and wrote it up as a skill for Claude Code and Codex: &lt;a href="https://dev.to/bozhidar_valchev/pipeline-for-translating-books-with-claude-code-and-codex-1k4k"&gt;Pipeline for Translating Books with Claude Code and Codex&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version of this article is here: &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator-detailed/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator – Detailed&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>translation</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>In Search of the Best A.I. Literary Translator</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:08:40 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-hhf</link>
      <guid>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-hhf</guid>
      <description>&lt;p&gt;My wife reads faster than books get translated into Bulgarian. Some of the ones she wants don’t exist in Bulgarian and probably never will, because the market is small and the translation doesn’t pay for itself.&lt;/p&gt;

&lt;p&gt;So I tried machine translation. It came out unreadable: sentences that are grammatically correct and mean nothing, and dialogue punctuated the English way. My wife got to page thirty and gave up.&lt;/p&gt;

&lt;p&gt;I started with &lt;a href="https://www.deepl.com" rel="noopener noreferrer"&gt;DeepL&lt;/a&gt;, which everyone calls the best. It turned out to be all talk, and later in the test it came last of ten.&lt;/p&gt;

&lt;p&gt;The same happened with qwen3.8-max. I only included it because it is supposedly the best model for translation. It came second to last and was the only one that dropped parts of the text. All talk again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested the AI translators
&lt;/h2&gt;

&lt;p&gt;So I decided to run my own study and find out which AI literary translator really does the job best. I took a Robert E. Howard story of about 12,000 words that also has a published human translation, and gave it to eight language models and to DeepL. Every one of them got the same instructions, word for word.&lt;/p&gt;

&lt;p&gt;For the scoring I picked four models from four different makers. Each has a relative among the translators and may go a little easy on it, so with four the bias gets diluted, and I can also see whether their scores agree. I set a few rules as well. The judges don’t know who translated what, and they score all the translations at once, so they measure on the same scale. I also checked whether the models had seen the published translation during training, because then they would simply be remembering it. I found no sign of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which AI literary translator came out on top
&lt;/h2&gt;

&lt;p&gt;Here is how they ranked. One judge gave only an order without points, so the chart shows three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" alt="Round one: ten candidates, three judges" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Points are one thing. The other is what translating the same story costs with each model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth7foww7ye2uah0xcjve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth7foww7ye2uah0xcjve.png" alt="What one 12,000-word story costs to translate" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline adds
&lt;/h2&gt;

&lt;p&gt;Based on these results I added a few steps from the pipeline of &lt;a href="https://github.com/BigDawnGhost/wenyi" rel="noopener noreferrer"&gt;wenyi&lt;/a&gt;, a tool for translating books. The model first reads the whole book and builds a summary and a glossary of names, and after translating it reviews its own text and fixes what it finds.&lt;/p&gt;

&lt;p&gt;I ran the four best models again, this time through the full pipeline. In the chart below each of them has three versions: the plain translation, the same translation with a review added, and a new translation through the full pipeline. gpt-6-astra and gemini-3.1-pro gain one or two points, while gemini-3.8-flash and deepseek-v4-pro lose one, and each set of three was scored separately, so compare it only with itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5exn1arzhtlhqhmjlkho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5exn1arzhtlhqhmjlkho.png" alt="What the pipeline does to the score" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The human translation, and the judge as a translator
&lt;/h2&gt;

&lt;p&gt;I also scored the published human translation. It came in below the best machines, and the main reason is completeness. The translator cuts on purpose: of 125 departures from the original, 66 are deliberate choices, and the scoring counts every one of them as a loss, even when the cut works. It also loses points on language. It keeps using the short definite article where Bulgarian grammar needs the full one: “кимериеца”, “вашия капитан”, “пътя ти”. There is wrong verb government too, such as “пусни ни борда”, and that one appears twice. About ten typesetting leftovers remain, such as a line-break hyphen in the middle of a word (“удивле-ние”), doubled words and Latin letters inside Cyrillic words. For Bulgarian it scores 6 out of 10, against 9 for the best model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" alt="The human translation, criterion by criterion" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Finally I had one of the judges, Fable 5.1, translate the story too, in the same three versions. Two outside models scored it together with all the other translations at once, so the scale was shared. It came right after gpt-6-astra, but I don’t count that as a win, because it was the only one that knew in advance what it would be judged on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq0tpmqnap04x4g8tjnc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq0tpmqnap04x4g8tjnc.png" alt="Round two: thirteen candidates, two outside judges" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So which model translates fiction best
&lt;/h2&gt;

&lt;p&gt;Overall, gpt-6-astra showed the best results in every round, but at a price. It is the most expensive model, more than 12 times the price of the cheapest one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9rbvuw9nksvho7ek6ne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9rbvuw9nksvho7ek6ne.png" alt="AI literary translator ranking: points and price" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gemini-3.8-flash gave the best value for money: it works fast, cheap and well enough.&lt;/p&gt;

&lt;p&gt;I also saw that using a pipeline during translation improves the results significantly, and if I set out to translate fiction myself, I would definitely do it with something like wenyi, even if only written up as a skill or a custom agent in the tool I use.&lt;/p&gt;

&lt;p&gt;In the end I did exactly that and wrote it up as a skill for Claude Code and Codex: &lt;a href="https://dev.to/bozhidar_valchev/pipeline-for-translating-books-with-claude-code-and-codex-1k4k"&gt;Pipeline for Translating Books with Claude Code and Codex&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here you can read the detailed article about the research: &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator-detailed/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator – Detailed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>translation</category>
      <category>deepl</category>
    </item>
    <item>
      <title>Download YouTube playlist and channel videos with yt-dlp</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 22:41:05 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/download-youtube-playlist-and-channel-videos-with-yt-dlp-30a4</link>
      <guid>https://dev.to/bozhidar_valchev/download-youtube-playlist-and-channel-videos-with-yt-dlp-30a4</guid>
      <description>&lt;p&gt;&lt;strong&gt;YT playlist downloader&lt;/strong&gt; is a single Python script that uses yt-dlp to download YouTube playlist videos in bulk. It handles entire channels and single videos too. It numbers the files in playlist order and remembers what it has fetched, so an interrupted run resumes where it stopped. A private or deleted video does not stop it either. The source is on GitHub under the MIT licence: &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;YT_playlist_downloader_by_Valcheff&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Use it for material you have the right to download. That covers your own uploads, Creative Commons and public domain video, or anything the uploader permits. DRM protected streams are out of scope, and yt-dlp cannot handle them at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;yt-dlp can already do everything below. The catch is that doing it well takes a very long command line. Most people rebuild it from the help output every time and quietly leave out the parts they forget. The script fixes the flags in place so a bulk download behaves sensibly by default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwp257bt4rongbj1dhq6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwp257bt4rongbj1dhq6.png" alt="Download YouTube playlist videos with yt-dlp: numbered files, a resume archive, unavailable items skipped" width="800" height="358"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Files keep the playlist order, the archive makes re-runs resume, and an unavailable video is skipped.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Files land in playlist order
&lt;/h3&gt;

&lt;p&gt;Each filename starts with its position in the playlist. A 40 part lecture series therefore stays in sequence on disk instead of sorting alphabetically by title. It also sanitises titles for Windows and caps them at 200 characters, so long names stay under the path limit.&lt;/p&gt;
&lt;h3&gt;
  
  
  Interrupted downloads resume
&lt;/h3&gt;

&lt;p&gt;The script writes every completed video ID to an archive file in the output directory. Stop a 300 item playlist at item 180 and run the same command a week later. It continues at 181 rather than starting over. Partially written files resume at the byte level too, so a video cut off midway is not downloaded twice.&lt;/p&gt;
&lt;h3&gt;
  
  
  One dead video does not stop the run
&lt;/h3&gt;

&lt;p&gt;The script logs and skips private, deleted and geo-blocked items, and the rest of the playlist still downloads. Without this, a single unavailable video at position 47 ends a 200 item run. That is the main reason unattended downloads fail overnight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.9 or newer&lt;/li&gt;
&lt;li&gt;yt-dlp installed with its default dependency group&lt;/li&gt;
&lt;li&gt;ffmpeg on PATH, for merging video with audio and for audio conversion&lt;/li&gt;
&lt;li&gt;deno 2.3.0 or newer, which yt-dlp uses to solve YouTube’s JavaScript challenge
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"yt-dlp[default]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The quotes and the bracketed group matter. A plain &lt;code&gt;pip install -U yt-dlp&lt;/code&gt; omits the challenge solver, which produces a misleading failure covered in the troubleshooting section.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download YouTube playlist videos
&lt;/h2&gt;

&lt;p&gt;Start by listing the playlist. This prints the contents with numbers and durations and downloads nothing. It matters because a channel URL with 1100 videos looks identical to a playlist with 12 until you check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/playlist?list=PLxxxx"&lt;/span&gt; &lt;span class="nt"&gt;--list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then take the first two items as a smoke test. It catches a wrong output path or a missing ffmpeg before a long run does:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--items&lt;/span&gt; 1-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If those arrive correctly, drop the item range and download the whole playlist:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/playlist?list=PLxxxx"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Files arrive as &lt;code&gt;D:/Video/&amp;lt;playlist name&amp;gt;/01 - Title [videoID].mp4&lt;/code&gt; and so on. Pass &lt;code&gt;--flat&lt;/code&gt; if you would rather skip the per-playlist subfolder.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download an entire YouTube channel
&lt;/h2&gt;

&lt;p&gt;Point it at the channel’s videos tab. Channels list newest first. Add &lt;code&gt;--reverse&lt;/code&gt; to make the oldest video file number one, which suits a course or a series:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/@channel/videos"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Channel"&lt;/span&gt; &lt;span class="nt"&gt;--reverse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Large channels are worth splitting into batches with &lt;code&gt;--items&lt;/code&gt;, for example &lt;code&gt;1-100&lt;/code&gt; today and &lt;code&gt;101:&lt;/code&gt; tomorrow. The archive file makes the split safe, because the second run skips anything the first one finished.&lt;/p&gt;

&lt;p&gt;To keep a channel mirrored, save the URLs in a text file and re-run the same command whenever you like. It downloads only the new uploads:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="nt"&gt;-f&lt;/span&gt; channels.txt &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  How to download audio only
&lt;/h2&gt;

&lt;p&gt;To download YouTube playlist audio without the video, add &lt;code&gt;--audio-only&lt;/code&gt;. For podcasts, lectures and music this cuts roughly ninety percent of the transfer. The script never touches the video stream:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Podcasts"&lt;/span&gt; &lt;span class="nt"&gt;--audio-only&lt;/span&gt; &lt;span class="nt"&gt;--audio-format&lt;/span&gt; opus &lt;span class="nt"&gt;--thumbnail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Choose opus rather than mp3 where your player allows it. YouTube already serves audio as opus or m4a. Requesting mp3 makes ffmpeg re-encode an already lossy stream, which adds a second round of loss. With opus you keep the original bytes. mp3 is worth it only for hardware that cannot play anything else. flac is counterproductive: it multiplies the file size without recovering detail YouTube has already discarded. The &lt;code&gt;--thumbnail&lt;/code&gt; flag embeds the video thumbnail as cover art, which is what most music players display.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download videos with subtitles
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Course"&lt;/span&gt; &lt;span class="nt"&gt;--subs&lt;/span&gt; &lt;span class="nt"&gt;--auto-subs&lt;/span&gt; &lt;span class="nt"&gt;--sub-langs&lt;/span&gt; en,bg &lt;span class="nt"&gt;--embed-subs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Without &lt;code&gt;--auto-subs&lt;/code&gt; you get only captions the uploader typed by hand, which for most amateur video means none at all. Adding it brings in YouTube’s machine transcript, which is useful for searching through lectures even though it reads poorly. &lt;code&gt;--embed-subs&lt;/code&gt; writes the subtitles inside the video file instead of leaving separate &lt;code&gt;.srt&lt;/code&gt; files next to it. That matters for players that fail to pick up external subtitle files.&lt;/p&gt;
&lt;h2&gt;
  
  
  Choosing quality and file size
&lt;/h2&gt;

&lt;p&gt;The default ceiling is 1080p. It controls size more than quality, because 4K files are five to eight times larger than 1080p ones. The selector always takes the best format below the ceiling. A 480p source stays 480p, and the script never upscales it.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 2160 &lt;span class="c"&gt;# up to 4K&lt;/span&gt;
python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 720 &lt;span class="c"&gt;# small files&lt;/span&gt;
python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 0 &lt;span class="c"&gt;# no limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Output goes into an mp4 container by default, which plays on essentially everything and spares media servers from transcoding. Switch to &lt;code&gt;--container mkv&lt;/code&gt; when you are pulling several subtitle languages or want chapters preserved, since mp4 handles both badly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Resuming and re-running
&lt;/h2&gt;

&lt;p&gt;The script records completed video IDs in &lt;code&gt;.downloaded.txt&lt;/code&gt; inside the output directory, and every later run skips them. Interrupt with Ctrl+C at any point and run the identical command afterwards to continue.&lt;/p&gt;

&lt;p&gt;Two consequences are worth knowing. Moving the finished files elsewhere does not clear the archive, so the script will not fetch them again. To download the same playlist again at a different quality, point &lt;code&gt;-o&lt;/code&gt; at a new directory or pass &lt;code&gt;--no-archive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For an overnight run on a shared connection, cap the speed and slow the request rate:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--rate-limit&lt;/span&gt; 3M &lt;span class="nt"&gt;--sleep&lt;/span&gt; 1 &lt;span class="nt"&gt;--fragments&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fragments deserve a word, because they are the main speed lever. A large video arrives in hundreds of fragments and the script downloads four at a time by default. Beyond roughly eight, YouTube throttles and intermittently returns HTTP 403. The retries that follow end up slower than a lower number would have been.&lt;/p&gt;
&lt;h2&gt;
  
  
  All options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Print the playlist contents and download nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-o DIR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Output directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--flat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip the per-playlist subfolder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--items SPEC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select items: &lt;code&gt;5-20&lt;/code&gt;, &lt;code&gt;3,7,12&lt;/code&gt;, &lt;code&gt;5:&lt;/code&gt;, &lt;code&gt;::2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--reverse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Walk the playlist from the end, for channels that list newest first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-height PX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolution ceiling, 1080 by default, &lt;code&gt;0&lt;/code&gt; for unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--container&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mp4, mkv or webm for the merged file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--audio-only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Download the audio stream only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--audio-format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;opus, m4a, mp3, flac, wav or vorbis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--subs&lt;/code&gt;, &lt;code&gt;--auto-subs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Subtitles, optionally including machine generated ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--embed-subs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write subtitles inside the video file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--thumbnail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embed the thumbnail as cover art&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--sponsorblock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cut sponsor segments using the SponsorBlock database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--fragments N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parallel fragment downloads per video, 4 by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--rate-limit RATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Speed ceiling such as &lt;code&gt;3M&lt;/code&gt; or &lt;code&gt;800K&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--sleep SECONDS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pause between requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--cookies-from-browser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use an existing browser session for your own private videos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-archive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ignore the resume archive and download everything again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Every download fails with “This video is not available”
&lt;/h3&gt;

&lt;p&gt;The message is wrong and the video is fine. YouTube hides its stream URLs behind a JavaScript challenge. yt-dlp solves it by running an external JavaScript runtime, deno by default, over solver scripts from the separate &lt;code&gt;yt-dlp-ejs&lt;/code&gt; package. When the solver is missing, no formats come back and yt-dlp reports the absence of formats as an unavailable video.&lt;/p&gt;

&lt;p&gt;Installing with the dependency group shown earlier pulls the solver in and fixes it. Official GitHub release binaries bundle it already, so this only bites pip installs. Verify both halves like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import yt_dlp_ejs; print('solver present')"&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; yt_dlp &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--simulate&lt;/span&gt; &lt;span class="s2"&gt;"https://www.youtube.com/watch?v=SOME_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The verbose output should report &lt;code&gt;JS runtimes: deno-&amp;lt;version&amp;gt;&lt;/code&gt; followed by &lt;code&gt;Solving JS challenges using deno&lt;/code&gt;. If it reports no supported runtime while deno is definitely installed, your terminal predates the installation. Its PATH is stale, so open a new one. Keep the two packages in step. yt-dlp raises the minimum solver version without announcing it and silently ignores an outdated one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Downloads are slow or return HTTP 403
&lt;/h3&gt;

&lt;p&gt;Lower &lt;code&gt;--fragments&lt;/code&gt; to 2 or 3 and add &lt;code&gt;--sleep 1&lt;/code&gt;. If it persists across a whole session rather than one video, YouTube is rate limiting your address. Waiting is the only real fix.&lt;/p&gt;
&lt;h3&gt;
  
  
  Merging fails or audio only produces nothing
&lt;/h3&gt;

&lt;p&gt;ffmpeg is not on PATH. Run &lt;code&gt;ffmpeg -version&lt;/code&gt; to confirm, and reopen the terminal if you installed it in this session.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Can it download an entire YouTube channel?
&lt;/h3&gt;

&lt;p&gt;Yes. Pass a channel videos URL such as &lt;a href="https://www.youtube.com/@channel/videos" rel="noopener noreferrer"&gt;https://www.youtube.com/@channel/videos&lt;/a&gt; and it walks every upload. Channels list newest first, so add –reverse for chronological order.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can it resume an interrupted playlist download?
&lt;/h3&gt;

&lt;p&gt;Yes. The script records completed video IDs in an archive file inside the output directory. Re-running the identical command skips them. Half finished files also resume at the byte level.&lt;/p&gt;
&lt;h3&gt;
  
  
  How many videos can it download at once?
&lt;/h3&gt;

&lt;p&gt;There is no built in limit, and playlists of several hundred items are the normal case. It fetches videos one after another while the fragments inside each video download in parallel. That keeps the request rate low enough to avoid throttling.&lt;/p&gt;
&lt;h3&gt;
  
  
  Do I need YouTube Premium?
&lt;/h3&gt;

&lt;p&gt;No. yt-dlp downloads the same streams an anonymous viewer receives. It never fetches ads, because YouTube delivers them separately from the video stream. A Premium account changes one thing: it makes the enhanced bitrate 1080p format available. yt-dlp labels that format Premium and prefers it automatically when you pass –cookies-from-browser. Everything else works identically without a subscription.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can it download private or age restricted videos?
&lt;/h3&gt;

&lt;p&gt;It can use your own browser session through –cookies-from-browser. That covers your own unlisted and private uploads and age gated material. It does not bypass any restriction; it only reuses the access you already have.&lt;/p&gt;
&lt;h3&gt;
  
  
  Which audio format should I choose?
&lt;/h3&gt;

&lt;p&gt;opus, because YouTube already serves opus and nothing needs re-encoding. Choose mp3 only for players that cannot handle anything else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;One script, one README documenting every option with the reasoning behind it, MIT licence: &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;github.com/valcheffnet/YT_playlist_downloader_by_Valcheff&lt;/a&gt;. It calls yt-dlp as a Python library rather than shelling out. Progress reporting, error handling and the resume archive all run through the API.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/valcheffnet" rel="noopener noreferrer"&gt;
        valcheffnet
      &lt;/a&gt; / &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;
        YT_playlist_downloader_by_Valcheff
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Download YouTube playlists and channels with yt-dlp: playlist-ordered filenames, resume across runs, and per-item error tolerance
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;YT playlist downloader&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A yt-dlp wrapper for pulling down whole YouTube playlists and channels without
having to remember twenty command line flags every time.&lt;/p&gt;
&lt;p&gt;It handles the parts you would otherwise write yourself. Filenames keep the
playlist order. Anything already downloaded gets skipped on the next run. And
when one item in a 200 video playlist turns out to be private, the other 199
still arrive.&lt;/p&gt;
&lt;p&gt;Meant for material you have the right to download. Your own uploads, Creative
Commons and public domain video, amateur footage, anything the uploader allows
It does not touch DRM protected streams and cannot be made to.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Prerequisites&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.9 or newer&lt;/td&gt;
&lt;td&gt;runs the script&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yt-dlp with the &lt;code&gt;default&lt;/code&gt; dependency group&lt;/td&gt;
&lt;td&gt;does the actual work&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python -m yt_dlp --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg on PATH&lt;/td&gt;
&lt;td&gt;merges video and audio, converts audio, embeds subtitles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ffmpeg -version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deno 2.3.0 or newer&lt;/td&gt;
&lt;td&gt;solves YouTube's JavaScript challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The post &lt;a href="https://valcheff.net/download-youtube-playlist-yt-dlp/" rel="noopener noreferrer"&gt;Download YouTube playlist and channel videos with yt-dlp&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>youtube</category>
      <category>cli</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
