YouTube Playlist Scraper avatar

YouTube Playlist Scraper

Pricing

from $0.85 / 1,000 video record delivereds

Go to Apify Store
YouTube Playlist Scraper

YouTube Playlist Scraper

Scrape any YouTube playlist by id or URL — including other people's public playlists, not just your own uploads: videoId, title, view count, position. No API key, no login.

Pricing

from $0.85 / 1,000 video record delivereds

Rating

0.0

(0)

Developer

Tim Zinin

Tim Zinin

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Share

YouTube Playlist Scraper: Extract Every Video From Any Public YouTube Playlist by ID or URL

How this Actor turns your input into a decision This Actor takes a YouTube playlist you already have — the list= value, or the full youtube.com/playlist?list=... URL — and returns its contents: every video's id, title, view count and position in the list. You do not need to own the playlist. You do not need a YouTube API key, a login, or a browser. One GET request per playlist, parsed, returned as flat JSON.

This is deliberately narrow. It accepts any playlist id you supply, including someone else's public curated collection — a competitor's "best of" list, a course creator's syllabus playlist, a brand's campaign playlist, your own channel's uploads if you happen to know its technical id. It does not look up a channel, does not guess which playlist belongs to which channel, and does not build YouTube's internal UU<channelId> uploads-playlist identifier for you. If what you actually have is a channel handle or URL and you want that channel's own recent uploads, the sibling Actor youtube-channel-videos-list does that resolution step for you and this one deliberately does not — see Evidence and boundaries for exactly why that split exists and which of the two you need.

The billable outcome is a delivered video: one row, successfully read from a playlist page you supplied, with a status of "ok" or "partial". Every other outcome is free: a playlist id that is correctly formed but does not exist (YouTube itself answers with HTTP 200 and zero videos — a genuine silent-empty result), a playlist id that is malformed enough that YouTube's own servers answer with HTTP 404, a request blocked by this Actor's own host guard before it ever reaches the network, or a run that stops early because its spend cap was reached. Those first two free outcomes look similar from the outside — "I got nothing back" — but they are not the same signal, and this page shows you the real JSON for both so you can tell them apart in your own pipeline. See Real happy, partial and failure output.

What you get

  • Any playlist id, not just your own uploads. The input field accepts a bare list= value or a full playlist URL, and this Actor never resolves a channel, never guesses an owner, and never restricts itself to playlists you can prove you control. If your use case is "what has this channel uploaded recently," read Who uses it below for the actor that fits that job instead.
  • A silent-empty result is never confused with a real failure, and neither ever costs you money. A well-formed but nonexistent playlist id gets status:"empty" with error:null. A malformed id gets status:"error" with the source's own HTTP 404 quoted in the message. Both are free. Neither is guessed at — the HTTP status is checked before this Actor ever tries to parse a response body, so a 404 can never be silently reinterpreted as "playlist found, zero videos."
  • A measured, disclosed ceiling — never a silent truncation. One playlist page returns at most 100 videos per request; this Actor does not paginate past that. When a response lands on exactly that ceiling, every row from that playlist is flagged partial:true with action:"flag-possible-truncation" rather than quietly presented as the playlist's complete contents. See Evidence and boundaries for the live measurement behind that number, taken while writing this page.
  • A missing title never fails the row. YouTube's own rendering occasionally omits a video's title on an otherwise well-formed playlist entry. This Actor treats title:null as data, not as an error — the row still ships as status:"ok" or "partial", with action:"flag-missing-title", so you can decide what to do with a nameless video id instead of losing the row entirely.
  • View counts are exactly what YouTube shows a visitor, no more precise than that. Every video row carries confidence:"display-text" as a standing reminder that viewCountText ("2.6M views") is YouTube's own rounded display string, not an exact counter pulled from a private API.
  • No transcripts, no comments — not a missing feature, a source boundary. YouTube's own robots.txt disallows /api/, /youtubei/, /results and /comment; the paths that serve transcripts and comment threads live behind those disallowed prefixes. This Actor's code never requests them, on any input, under any condition.
  • A hardened fetch layer shared across this Actor's whole hub. Every request goes through the same library (lib/youtube/fetch.js) used by three sibling YouTube Actors in this line: DNS-rebinding-safe SSRF guarding, a pinned-address connection so a re-resolution race cannot slip past the guard, a byte cap on the response stream, and manual redirect handling that re-runs the same host check on every hop rather than trusting the first one. A playlist "URL" pointing at a non-YouTube host is rejected before any request leaves the process — confirmed live below, not just asserted.
  • A billing guard that never charges twice for the same row and never bills a row it could not actually verify was delivered. Charging and writing a row happen inside one atomic, mutex-protected step; a push that throws is reported as "delivery unknown," never silently written off as either charged or free, because guessing wrong in either direction would be dishonest.

Who uses it

  • Content researchers and analysts studying a specific curated playlist — someone else's "top 10" list, a competitor's campaign playlist, a themed collection — where the unit of interest is the playlist itself, not a channel's whole upload history.
  • Course and learning-content aggregators cataloguing the video sequence of a public teaching playlist: order, titles, and rough popularity, without watching each video or scraping the page by hand.
  • Competitive intelligence teams auditing what a rival channel, brand, or influencer has assembled into a specific public collection, separate from that channel's general upload cadence.
  • Developers and workflow/agent builders who want one flat JSON row per video — status, partial, action, error — that a script, an n8n node, or an LLM agent can branch on directly, without parsing YouTube's HTML or its internal JSON view-model shapes by hand.
  • Not for buyers who only have a channel name/handle and want that channel's own recent uploads — that buyer does not want to look up or construct a playlist id at all. That job belongs to the sibling Actor youtube-channel-videos-list, which takes a channel and resolves its uploads playlist itself. Feeding a channel handle into this Actor's playlists field will not work — this Actor expects a list= value or a playlist URL, not a channel identifier, and does not attempt channel resolution as a fallback.

Where this Actor sits in your workflow

How to run

  1. Populate playlists with one or more values — each either a bare list= id (e.g. PLBCF2DAC6FFB574DE) or a full URL (https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE). Up to 50 per run; duplicates in the input are silently deduplicated before any request is made.
  2. Leave maxConcurrency at its default of 5 unless you have a specific reason to raise it (up to 20) or lower it (down to 1) — see Operating guide for what this number actually controls.
  3. Start the run from the Console, the Apify API, a scheduled Task, or an MCP client. The prefilled input (["PLBCF2DAC6FFB574DE"]) is a real, verified 10-video playlist — a default run with no changes produces a small, real, non-empty result at essentially no cost, so you can see the actual shape of the data before committing a larger list.
  4. Read the Dataset: one row per video for a playlist that returned content, or one summary row for a playlist that was empty, malformed, blocked, or held back by budget. Every row shares the same 13-field shape — see Field dictionary.
  5. Check status before anything else. "ok" and "partial" are the two billed, video-bearing outcomes; "empty" and "error" are always free and always carry videoId:null. Do not branch on whether error is set — an "empty" row correctly has error:null even though nothing was found; error is populated specifically on the "error" path, where the source itself refused the request.
  6. Check this run's key-value store record OUTPUT for a one-time run summary — requested/delivered/ paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of the run. Treat the Dataset itself as the complete, row-by-row record of what happened; OUTPUT is the roll-up, not a replacement for it.
  7. A partial:true row is still a delivered, billed row (when its status is "partial") — it is not an error and it is not free. It means "delivered, with a named, honest caveat attached." Read action to see which caveat applies.

Pricing

$0.005 per Actor start, plus $0.001 per delivered video. The primary pricing noun on this page, in the Dataset, and in this Actor's platform pricing record is the same throughout: the delivered video — one row, read from a playlist you supplied, with status:"ok" or status:"partial". A run of 50 playlists that mostly turn out empty or malformed is not charged per playlist attempted; it is charged for the videos it actually delivered, plus the one fixed start fee.

Both of this Actor's free outcomes are enforced in code, not left to a documentation promise: the charge and the dataset write happen together, inside one mutex-protected step (lib/youtube/billing.js), and a row is never pushed to the paid channel unless its status is already "ok" or "partial" at that point — an "empty" or "error" row is written through a separate, unconditionally free path. There is no code path in this build where a status:"empty" or status:"error" row is billed.

This Actor's own external data cost is close to zero — one GET request per playlist, no proxy, no LLM, no paid third-party API — so the per-video price reflects extraction, validation and delivery guarantees described on this page, not a resold data bill. On Apify's own billing record these are metered as two separate platform events — apify-actor-start for the run fee, and result-found for each delivered video — the same two lines you will see on your invoice.

Input contract

Exactly one field is required.

  • playlists — array of strings, 1 to 50 items, required. Each item is either a bare list= value (e.g. PLBCF2DAC6FFB574DE) or a full youtube.com/www.youtube.com/m.youtube.com playlist URL containing a list= parameter. A value containing /, .., or :// where a bare id is expected is rejected before any request is made (status:"error", no network call) — that guard exists to stop a path-injection or scheme-confusion attempt from being substituted into the request URL, not to police ordinary punctuation; a value with plain spaces or other non-path characters is deliberately not rejected client-side, and is instead sent to YouTube to receive the source's own honest answer (see the 404 example below — that is exactly this path). A URL whose host is not one of the three YouTube hosts above is rejected with status:"error" and an explicit "blocked host" message, and no request is ever sent to that host — verified live below, not just asserted.
  • maxConcurrency — integer, 1 to 20, default 5. How many playlists this run processes at once. See Operating guide for why the default is conservative.

Two runnable input examples, built directly from .actor/input_schema.json — these are input illustrations, not run output; the Real happy, partial and failure output section below shows the actual Dataset rows these kinds of inputs produce, taken from a real run of this exact build.

{
"playlists": ["PLBCF2DAC6FFB574DE"],
"maxConcurrency": 5
}

(This is the Actor's own prefilled default input — a single, verified real playlist, run at the default concurrency. This is what a buyer sees and can run with zero edits.)

{
"playlists": [
"https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE",
"UUBJycsmduvYEL83R_U4JriQ",
"PLBCF2DAC6FFB574DF"
],
"maxConcurrency": 2
}

(Mixes the full-URL input form with a bare-id form, and deliberately includes a playlist id that does not exist — PLBCF2DAC6FFB574DF — to show that a buyer does not need to pre-filter their own list before sending it in; the nonexistent one simply comes back as a free, honest "empty" row alongside the real results. All three ids in this example are the same ones this page's live evidence was gathered against.)

Real happy, partial and failure output

Every row in the Dataset shares one flat, 13-field shape regardless of outcome — requestedPlaylist, normalizedPlaylistUrl, videoId, position, title, viewCountText, source, status, confidence, partial, action, error, checkedAt. All four examples below are copied verbatim from an actual local run of this exact main.js build (apify run via node main.js with APIFY_LOCAL_STORAGE_DIR set to a local storage folder), executed while this page was being written, against the live YouTube playlist pages named in each block. Nothing here is invented or reconstructed from the schema — every field value below is what the Dataset actually contained after that run finished.

Happy path — a real 10-video playlist, position 1 of 10

PLBCF2DAC6FFB574DE is a real, publicly accessible YouTube playlist, verified independently in HUB10_GATE_EVIDENCE.md before this Actor was built and reconfirmed by this exact run. The run returned exactly 10 rows, positions 1 through 10, all status:"ok", all with a real videoId and a real title.

{
"requestedPlaylist": "PLBCF2DAC6FFB574DE",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE",
"videoId": "GvgqDSnpRQM",
"position": 1,
"title": "Andrew Willis, Skatepark Engineer",
"viewCountText": "2.6M views",
"source": "playlist-page",
"status": "ok",
"confidence": "display-text",
"partial": false,
"action": "ingest",
"error": null,
"checkedAt": "2026-08-17T20:17:57.854Z"
}

The tenth and last row of the same playlist, confirming the position count runs cleanly to the playlist's real end with no gaps:

{
"requestedPlaylist": "PLBCF2DAC6FFB574DE",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE",
"videoId": "CyRQJBBVI7g",
"position": 10,
"title": "Cheryl and Morgan: Learning Independence",
"viewCountText": "3.6M views",
"source": "playlist-page",
"status": "ok",
"confidence": "display-text",
"partial": false,
"action": "ingest",
"error": null,
"checkedAt": "2026-08-17T20:17:57.854Z"
}

Both rows above are billed, delivered videos — status:"ok", partial:false, action:"ingest".

Partial path — the measured 100-item ceiling, flagged honestly

To measure this Actor's real per-request ceiling, the same run also requested UUBJycsmduvYEL83R_U4JriQ — a large, publicly accessible playlist already verified in HUB10_GATE_EVIDENCE.md on both a local network path and a datacenter network path, with exactly 100 videoIds returned on both. This Actor's playlists field accepts any list= value with no channel resolution involved, so using this particular id here is simply "a large playlist," feeding the exact same /playlist?list=... code path as any other input — not a boundary crossing into the sibling channel Actor's territory (see Evidence and boundaries for the full reasoning).

The run returned exactly 100 rows for this playlist — no more, no fewer — and every single one of them was flagged partial:true, action:"flag-possible-truncation", because the extraction layer's own sawExactly100 check fired on the raw count returned by the page, before this Actor's own defensive cap was even applied. Position 1 of that 100:

{
"requestedPlaylist": "UUBJycsmduvYEL83R_U4JriQ",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=UUBJycsmduvYEL83R_U4JriQ",
"videoId": "o4SSoURPODY",
"position": 1,
"title": "Google Pixel 11/Pro/Fold Impressions: It Is What It Is",
"viewCountText": "2.9M views",
"source": "playlist-page",
"status": "partial",
"confidence": "display-text",
"partial": true,
"action": "flag-possible-truncation",
"error": null,
"checkedAt": "2026-08-17T20:17:58.418Z"
}

Position 100 of the same playlist, the last row this Actor will ever return for it in a single run:

{
"requestedPlaylist": "UUBJycsmduvYEL83R_U4JriQ",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=UUBJycsmduvYEL83R_U4JriQ",
"videoId": "HP_mXUuHPbo",
"position": 100,
"title": "Samsung broke this",
"viewCountText": "7M views",
"source": "playlist-page",
"status": "partial",
"confidence": "display-text",
"partial": true,
"action": "flag-possible-truncation",
"error": null,
"checkedAt": "2026-08-17T20:17:58.418Z"
}

These rows are still billed — status:"partial" is one of the two billable statuses — but every one of them carries the honest flag that this playlist may hold more than 100 videos and this Actor, by design, never fetches a second page to find out. On this specific run, all 100 titles were present — the separate "missing title" trap described next did not fire on this particular fetch. It is a real, disclosed possibility in this Actor's own code (see below), not something reproducible on demand; a buyer relying on this page for a specific missing-title example should treat title:null as expected behavior in general, not assume it is rare because it did not happen on this one run.

Empty path — a well-formed, nonexistent playlist id, HTTP 200 with zero videos

PLBCF2DAC6FFB574DF — the real evidence playlist id above with its final character changed — is syntactically indistinguishable from a real playlist id and was included in the same run specifically to capture this exact outcome live. YouTube answered with HTTP 200 and a real page body (roughly 759 KB, independently reconfirmed by a direct curl request made while writing this page), containing zero video entries. This Actor's extraction step checked the HTTP status first, found it healthy, then parsed the body and found nothing — which is exactly what makes this a silent-empty result rather than an error:

{
"requestedPlaylist": "PLBCF2DAC6FFB574DF",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DF",
"videoId": null,
"position": null,
"title": null,
"viewCountText": null,
"source": "playlist-page",
"status": "empty",
"confidence": null,
"partial": false,
"action": "skip-not-found",
"error": null,
"checkedAt": "2026-08-17T20:17:57.793Z"
}

error is null here, not a message — this is the row shape's own way of saying "the source answered successfully, and the honest answer was nothing." This row is free. Nothing about it looks like a failure to a downstream consumer that only checks error, which is exactly why status is the field to branch on, never error alone.

Error path — a malformed playlist id, the source's own honest HTTP 404

The same run also requested this is not a real playlist id !! — a value containing spaces and punctuation that this Actor's own client-side guard deliberately does not reject (see Input contract: the guard only blocks /, .. and ://), specifically so a value like this reaches YouTube and receives the source's own real answer rather than a guessed client-side rejection. YouTube's servers answered with a genuine HTTP 404, checked and recorded before any attempt was made to parse a body:

{
"requestedPlaylist": "this is not a real playlist id !!",
"normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=this%20is%20not%20a%20real%20playlist%20id%20!!",
"videoId": null,
"position": null,
"title": null,
"viewCountText": null,
"source": "playlist-page",
"status": "error",
"confidence": null,
"partial": false,
"action": "skip-not-found",
"error": "playlist not found (HTTP 404) — http 404",
"checkedAt": "2026-08-17T20:17:58.047Z"
}

This row is also free — an "error" status is never billed, same as "empty". The distinction that matters to you as a buyer: status:"empty" means "this playlist genuinely has nothing in it right now," and status:"error" means "the source itself refused this specific request." Neither ever costs anything, and this Actor never collapses one into the other.

Field dictionary

FieldMeaningImportant boundary
requestedPlaylistExactly the string you supplied in playlists, unmodifiedEchoed even when the value was rejected before any request — lets you match every input back to its outcome
normalizedPlaylistUrlThe full youtube.com/playlist?list=... URL this Actor actually requestednull when the input was rejected before a URL could be built (invalid value, blocked host)
videoIdThe video's 11-character YouTube idnull on every summary row — empty, error, or budget-stopped — never present without a real video behind it
position1-based order of this video within the fetched pagenull on every summary row; not guaranteed stable across separate runs if the source itself reorders a playlist between requests
titleThe video's title as rendered on the playlist pageNullable by design — a missing title never turns the row into an error; see action:"flag-missing-title"
viewCountTextYouTube's own rounded display text (e.g. "2.6M views")Not an exact count — see confidence
sourceFixed constant "playlist-page" on every rowPresent even on a row where no request actually reached YouTube (e.g. an SSRF-blocked or invalid-value row) — it names the kind of page this Actor reads, not proof that a fetch happened; check status/error for that
statusThe billing and outcome boundary"ok" or "partial" = billed, video-bearing row. "empty" or "error" = free, videoId:null, no exceptions in this build's code
confidenceFixed "display-text" on every video rownull on every summary row; exists specifically to keep viewCountText's imprecision visible at the row level, not buried in documentation only
partialtrue when this row carries a disclosed caveatStill billed when status:"partial"partial:true is not the same thing as free
actionWhat this row suggests doing with it"ingest" (clean), "flag-missing-title", "flag-possible-truncation", or "skip-not-found" — the last one is the fixed default on every summary row (empty, error, SSRF-blocked, or budget-stopped), not a literal claim that something specific was "not found" in every case; read status/error for the real reason
errornull on every honestly-completed row, including "empty"Populated only on status:"error" and on free notice rows (budget-exhausted, blocked-host); when present, often quotes the source's own response text rather than a message this Actor composed
checkedAtISO-8601 timestamp of this row's outcomePer-row, not per-run — rows from the same run can carry slightly different timestamps under concurrency

Evidence and boundaries

Why this Actor takes an arbitrary list= value and performs no channel resolution. The hub this Actor belongs to also ships youtube-channel-videos-list, which takes a channel handle/URL/UC... id and builds that channel's technical UU... uploads-playlist id internally before fetching it — the buyer for that Actor never sees or supplies a playlist id at all. This Actor solves a different, narrower problem: a buyer who already has a specific playlist id or URL — often one they do not own, like a competitor's curated collection or a course creator's syllabus — and wants its contents without any assumption about which channel, if any, "owns" it in a meaningful sense. Building both resolution paths into one Actor would blur that distinction for the buyer and for this Actor's own pricing and input contract; keeping them separate means each Actor's input field means exactly one thing.

The two-part negative case, proven live, not asserted. A correctly formed but nonexistent playlist id and a syntactically broken one look identical from a distance — "nothing came back" — but they are different signals from the source, and this Actor's code checks the HTTP status before it ever attempts to parse a response body, specifically so the two paths can never be confused with each other. The live run behind the two JSON blocks above confirms both halves on the same execution: PLBCF2DAC6FFB574DF (well-formed, nonexistent) returned HTTP 200 with a genuine ~759 KB page body and zero video entries — status:"empty", error:null. this is not a real playlist id !! (syntactically broken) returned a real HTTP 404 from YouTube's own servers — status:"error", error naming the 404 explicitly. Both are free. A parser that treated "any non-404 response" as "playlist found" would have silently sold the first case as a successful, if boring, result — this build's own test suite (acceptance.json goldens 2 and 3) exists specifically to catch that class of regression before release.

The 100-item ceiling, measured live while writing this page, not assumed from documentation. A direct curl request against UUBJycsmduvYEL83R_U4JriQ made independently of the Actor run above (with the same Cookie: CONSENT=YES+1 header and hl=en&gl=US query parameters this Actor always sends) returned HTTP 200 and roughly 2.06 MB of HTML — in the same order of magnitude as HUB10_GATE_EVIDENCE.md's own earlier measurement of the same playlist (2,059,988 bytes on a local network path, 2,066,685 bytes from a Contabo datacenter path, both also 100 videoId values). This Actor's own live run above independently extracted exactly 100 unique video entries from that same page — not 100 because of an artificial slice, but because the extraction layer's own count, taken before this Actor's separate defensive MAX_ITEMS_PER_PLAYLIST cap is even applied, already landed on exactly 100. The 10-video playlist used for the happy-path example above, fetched in the same run, did not trigger this flag — partial stayed false on all 10 of its rows — confirming the flag fires on the measured ceiling specifically, not on "any moderately large playlist."

Why the flag on a 100-row result says "possible truncation," not "definitely truncated." A playlist with exactly 100 videos and a playlist with 1,000 videos are indistinguishable from the outside of a single GET request — both render the same first 100 entries and no visible signal that says "this is where it stops." This Actor has no pagination fallback beyond one request per playlist, so rather than either quietly presenting a 100-row result as "the whole playlist" (which could be wrong) or refusing to deliver those 100 rows at all (which would withhold real data that is correct as far as it goes), every row from a page that hit the ceiling is billed and delivered with partial:true — an honest, disclosed uncertainty rather than a silent guess in either direction.

The missing-title trap is real and disclosed, even though it did not reproduce on this page's own evidence run. HUB10_GATE_EVIDENCE.md records a separate, earlier live measurement of this exact playlist (UUBJycsmduvYEL83R_U4JriQ) that returned 99 titles out of 100 video entries — one position with a real videoId and no title at all. This Actor's code treats that outcome as data, not failure: title:null never changes a row's status away from "ok"/"partial", it only sets action:"flag-missing-title". On the specific run this page's own JSON examples were pulled from, all 110 delivered video rows (10 from the happy-path playlist, 100 from the ceiling playlist) carried a non-null title — YouTube's own rendering is not perfectly deterministic across separate requests, and this Actor's own acceptance suite (acceptance.json golden 4) is written to accept either outcome for exactly that reason, rather than pin a specific position that may or may not repeat.

The SSRF/host guard, confirmed live, not just read from source. A separate run of this Actor with playlists: ["https://evil.example.com/playlist?list=X"] was made specifically to confirm the guard described in Input contract. The result: status:"error",

error:"blocked host (not a youtube.com URL): evil.example.com"
, normalizedPlaylistUrl:null — no request was ever sent to evil.example.com. This guard, and the DNS-rebinding protection and pinned-connection logic behind it in lib/youtube/fetch.js, is shared, unmodified, across every YouTube Actor in this hub — a change made here changes all of them, and none of them "simplify" it away, per the guard file's own header comment.

Why robots.txt is read as permission for /playlist, checked live, not cached from a prior date. A direct request to www.youtube.com/robots.txt, made independently while writing this page, confirmed the disallow list explicitly names /api/, /comment, /results, and /youtubei/ — and does not name /playlist. This matches HUB10_GATE_EVIDENCE.md's own earlier reading of the same file. This Actor's own code never requests any of the four disallowed prefixes above, on any input, under any condition — which is also the reason it can never return video transcripts or comment threads: the paths that would serve them are the exact ones robots.txt disallows.

Why the consent cookie and language parameters are sent on every request, without exception. Every request this Actor makes carries Cookie: CONSENT=YES+1 and hl=en&gl=US. Without both, YouTube renders the page in whatever language the runner's own network location implies, and this Actor's English-token parsing logic would return a silent-empty result on a clean HTTP 200 — a failure mode that looks identical to a genuinely empty playlist unless you already know to suspect it. This is a fixed requirement of the shared fetch layer, not a per-request option.

Private and access-restricted playlists — not separately measured for this build. This Actor sends no authentication of any kind — no login, no session, no API key — so it can only ever see what YouTube serves to an anonymous visitor. A private or unlisted-with-restrictions playlist is expected, by the nature of an unauthenticated request, to be unreachable in the same way any other anonymous visitor would find it unreachable. What exact response shape that produces — a 200 with no content, a 404, a redirect to a sign-in page — was not independently measured for this specific build before this page was written, and no live example of it exists in this evidence set. Treat a private playlist as simply outside this Actor's reach rather than assuming it will surface as any one specific status value.

Decision routing

statusaction values seenBillable?What it means
"ok""ingest"YesA video was read cleanly — title present, page not at the 100-item ceiling
"partial""flag-missing-title" or "flag-possible-truncation"YesA video was delivered with one disclosed caveat — a missing title, or this page landed on the measured 100-item ceiling
"empty""skip-not-found"NoA correctly formed playlist id that currently has no videos in it — HTTP 200, genuinely empty
"error""skip-not-found"NoThe playlist id was rejected before any request (invalid value, blocked host), or the source itself refused the request (e.g. HTTP 404)

A workflow needs only two rules to be correct: bill and use only rows where status is "ok" or "partial"; and never assume "empty" and "error" mean the same thing even though both are free and both carry videoId:null — read error to tell them apart, since only the "error" path populates it.

Commercial playbooks

Auditing a competitor's or influencer's curated collection

A brand or agency researcher takes the exact playlist URL of a competitor's "best of" list, a rival creator's campaign playlist, or an industry account's themed collection, and pulls the full ordered contents — titles and rough popularity by view count — without opening YouTube and scrolling by hand or building a bespoke scraper for a one-off research task.

Course and syllabus cataloguing

A learning-content aggregator or course reviewer feeds in the playlist URLs of public teaching playlists — a university department's lecture series, an independent instructor's course sequence — and gets back the exact video order and titles as a structured catalog entry, ready to cross-reference against a syllabus or a course-comparison table.

Lightweight research pipeline feeding an agent or n8n workflow

A researcher or workflow builder who already discovers playlist URLs some other way — a web search, a manual list, a scraped page of links — routes each one through this Actor to turn "I found a playlist URL" into structured, machine-checkable rows without writing a YouTube-specific parser, checking status and partial before treating any row as final.

Integration recipes

One-shot batch lookup

Feed a list of playlist URLs or ids collected from any source — a spreadsheet, a search result set, a manual list — into one run with maxConcurrency set to match the batch size (up to 20). Filter the resulting Dataset to status in ["ok", "partial"] before loading it into a catalog or spreadsheet; "empty"/"error" rows are the free record of what did not resolve, not data to import.

Agent/MCP pattern

An agent that has already located a playlist URL (via search, a page it read, or a user-supplied link) passes it directly into playlists, reads status and partial before treating any row as usable, and never assumes a status:"ok" row with title:null is broken — that combination is a valid, action:"flag-missing-title" outcome, not a parsing failure the agent needs to retry.

Spreadsheet/catalog append pattern

Dataset rows are appended keyed by videoId for "ok"/"partial" rows, and by requestedPlaylist + checkedAt for "empty"/"error" summary rows (which carry no videoId). null fields are preserved as null, not coerced into an empty string, so a downstream query can tell "this video has no title according to YouTube" apart from "this field was never populated."

Operating guide

Choosing maxConcurrency. The default of 5 is a deliberately conservative starting point, not a number derived from a sustained-load production measurement — this Actor's own SPEC document states plainly that the ceiling this input allows (20) has not been separately load-tested at scale. Raising it speeds up a run with many playlists in one input; it does not change what any individual playlist request returns, since each playlist is fetched with exactly one request regardless of concurrency setting.

Reading the 100-item ceiling flag correctly. partial:true with action:"flag-possible-truncation" on every row of a 100-row result means exactly one thing: this Actor does not know, and does not claim to know, whether the real playlist has 100 videos or many more than that. It never re-requests a second page and never estimates a true count. If your use case genuinely needs a playlist's complete contents and you have reason to believe it may exceed 100 videos, treat every row from that request as a sample of the first 100, not a guaranteed-complete answer.

Reading the missing-title flag correctly. partial:true with action:"flag-missing-title" applies to exactly the one row missing its title, not to the whole playlist's result set — a 100-row result with one missing title carries 99 ordinary "ingest" rows with partial:false (unless the same page also hit the ceiling, in which case every row already carries partial:true from that flag alone) and one row with partial:true/"flag-missing-title". The two flags are independent and can both apply.

Telling "empty" and "error" apart in your own code. Never write a check that treats "any row with no videoId" as a failure. Check status specifically. "empty" is a legitimate, free, non-error outcome — the playlist id was valid and the source answered normally; there was simply nothing to report. "error" means the value was rejected before a request, or the source itself refused the request. Conflating the two in a downstream alert or retry policy will produce false alarms on every genuinely empty playlist you check.

Related tools for adjacent workflows in social platforms and regional directories.

ActorWhat it does
YouTube Channel Videos ListPair it in the social platforms and regional directories workflow: List up to 100 most recent uploads for any YouTube channel: videoId, title, view count, and publish age...
Substack Publication ScraperPair it in the social platforms and regional directories workflow: Read recent posts from any Substack publication's own domain — title, date, full HTML body, comment count...
YouTube Channel IntelPair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full...
YouTube Channel Lookalike FinderPair it in the social platforms and regional directories workflow: Find the similar/lookalike channels YouTube itself shows on any channel's About page: channelId, name,...
Bluesky Profile ScraperPair it in the social platforms and regional directories workflow: Batch-fetch public Bluesky profiles by handle or DID via the official keyless AppView API — display name,...

FAQ

Can I use this to get my own channel's recent uploads?

Only if you already know your channel's technical UU... uploads-playlist id and supply it directly as a list= value — this Actor will fetch it exactly like any other playlist, as demonstrated by the 100-item example above, which deliberately reused a real uploads-playlist id as a large-playlist test case. If you do not already know that id, or you would rather supply a channel handle/URL/UC... id and have the uploads playlist resolved for you, use the sibling Actor youtube-channel-videos-list instead — that is exactly the problem it solves, and it also adds upload-age data for the first 30 videos, which this Actor does not attempt at all (see Limits).

What happens if I give it a playlist id that doesn't exist?

A free row with status:"empty" and error:null — YouTube answers HTTP 200 with a real page and zero video entries. See the live example under Real happy, partial and failure output. Nothing is billed.

What happens if I give it a garbled or malformed value?

A free row with status:"error", and error naming the source's own HTTP 404 response. See the live example under Real happy, partial and failure output. Nothing is billed.

Is there a way to get more than 100 videos from one playlist?

Not in this build. One playlist page returns at most 100 videos per request, measured live and confirmed against an independent historical measurement (see Evidence and boundaries), and this Actor performs exactly one request per playlist with no pagination fallback. A result that lands on that ceiling is flagged partial:true rather than silently presented as complete.

Are the view counts exact numbers?

No. viewCountText is YouTube's own rounded display text ("2.6M views"), exactly as rendered to a visitor on the page. confidence:"display-text" is set on every video row as a standing reminder of that. There is no numeric, exact-count field in this build.

Does this Actor return upload date or video age?

No, not at all — this build has no age field of any kind, unlike the sibling youtube-channel-videos-list Actor, which cross-references a channel's /videos page for that data. This Actor has no equivalent cross-reference available for an arbitrary third-party playlist, so it does not attempt to estimate or guess an age field rather than publish an unreliable one.

Does this Actor return transcripts or comments?

No. YouTube's own robots.txt disallows the paths that would serve either, and this Actor's code never requests them under any input.

Can it read a private or restricted playlist?

No authentication of any kind is sent, so this Actor can only see what an anonymous visitor sees. See Evidence and boundaries for the honest disclosure that the exact failure shape for a private playlist was not separately measured for this build.

How is this different from youtube-channel-videos-list?

This Actor takes a playlist id or URL you already have, including someone else's — it performs zero channel resolution. youtube-channel-videos-list takes a channel (handle, URL, or UC... id) and builds its uploads-playlist id for you — the buyer for that Actor never needs to know or supply a playlist id. They are two different input contracts for two different buyer intents, sharing the same underlying fetch and parsing library.

Sources and rights

Source family: YouTube public channel and playlist pages. This Actor reads the playlist page you name, anonymously, with no login, no API key and no browser session.

The only source. The public HTML page at https://www.youtube.com/playlist?list=<id>, fetched anonymously with Cookie: CONSENT=YES+1 and ?hl=en&gl=US on every request. No YouTube Data API, no API key, no OAuth, no login session.

robots.txt, checked fresh, not cached. www.youtube.com/robots.txt disallows /api/, /comment, /results, and /youtubei/; it does not disallow /playlist, the only path this Actor requests. Confirmed live during the writing of this page, and consistent with the independent, earlier reading recorded in HUB10_GATE_EVIDENCE.md.

No explicit reuse licence — unlike a government open-data feed. YouTube publishes no equivalent of an Open Government Licence for the content of its playlist pages. This Actor's engineering posture is limited to what robots.txt permits to be crawled and what a genuinely anonymous request receives; it does not constitute legal advice about how you may use the resulting data downstream, and this page makes no claim of an explicit redistribution licence the way a government open-data source would carry one. If your use case depends on a specific reuse right beyond what an anonymous, robots.txt-permitted fetch implies, that determination is yours to make, not this Actor's to certify.

The consent cookie is a standard region/consent flow, not a bypass. CONSENT=YES+1 is the same cookie value YouTube's own consent-interstitial flow sets for a visitor who accepts it; sending it directly on each request avoids the page rendering a consent wall instead of playlist content. It is not an authentication credential and grants no access beyond what any anonymous visitor who clicked "accept" would already see.

Limits

Two different negative outcomes exist and this Actor never confuses them, and never bills either. A correctly formed but nonexistent playlist id returns HTTP 200 with zero video entries — a genuine silent-empty result, status:"empty", error:null. A syntactically malformed playlist id returns the source's own HTTP 404status:"error", error naming the 404 explicitly. Both are free in every code path this build has. Neither is treated as, or silently converted into, the other. See Real happy, partial and failure output for the live JSON of both, taken from the same run.

  • Private and access-restricted playlists are not reachable — this Actor sends no authentication of any kind, and the exact response shape a private playlist produces was not separately measured for this build (see Evidence and boundaries).
  • View counts (viewCountText) are YouTube's own rounded display text, never an exact number — there is no exact-count field in this build.
  • No transcripts and no comments, ever — the paths that would serve either are disallowed by YouTube's own robots.txt, and this Actor's code never requests them.
  • The measured per-request ceiling is exactly 100 videos per playlist page; this Actor performs one request per playlist with no pagination, and flags a result that hits that ceiling as partial:true rather than presenting it as complete.
  • No upload date or video-age field of any kind — unlike the sibling youtube-channel-videos-list Actor, this one has no companion source page to cross-reference age against for an arbitrary third-party playlist.
  • No channel resolution — this Actor never looks up, guesses, or builds a channel's uploads-playlist id. A buyer who only has a channel name needs youtube-channel-videos-list instead.
  • A missing video title never fails a row, but it also cannot be predicted or guaranteed absent — it is disclosed as a possibility, not a fixed rate; the specific run behind this page's own evidence did not reproduce it, an earlier independent measurement of the same playlist did (99/100 titles).
  • maxConcurrency accepts up to 20, but that upper end has not been separately load-tested against sustained production traffic for this build; the default of 5 is the conservative, verified starting point.
  • The billing safety nets described in this Actor's code — refusing to run at all when the platform's own pricing configuration is broken, stopping mid-run when a spend cap is reached, failing the run outright if the remaining budget itself cannot be read — are real, reviewed code paths, but none of them were exercised during this evidence-gathering session, because a local, off-platform run never engages the platform's own charging manager. They are documented from the code, not from a live, monetized observation.

Support boundary

Actor support covers this Actor's own input validation, the two-part negative-case behavior, the 100-item ceiling flag, the missing-title handling, the SSRF/host guard, and the Dataset schema described on this page — all of which are backed by a live, verified run referenced above. It does not cover whether a specific third-party playlist is a good research target, does not restore access if YouTube changes this page's markup or its anti-bot posture in a way that breaks extraction, does not cover private-playlist access (never supported, not a regression), and does not provide legal guidance on how scraped playlist data may be reused downstream. When reporting a problem, include the run id, the exact requestedPlaylist value, the status and error returned, and the checkedAt timestamp of the affected row.