Module:Studio sessions: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Magent (talk | contribs)
Split into Creative Commons and other studio work, and name whose record it is (via update-page on MediaWiki MCP Server)
Magent (talk | contribs)
Move recordings onto composition pages: records and musicians now read them rather than holding them (via update-page on MediaWiki MCP Server)
Line 1: Line 1:
-- Studio sessions: who played what, on which track, on which record.
-- Studio recordings: who played what, on which recording of which composition.
--
--
-- The data comes from arthel, where it exists because the Rabbithole player
-- The composition is the page. A recording of it is a subobject on that page,
-- needs it to follow a picker from one track to the next. Here it answers the
-- naming the record it was cut for and everyone who played on it. See
-- other direction: given a musician, which records are they on.
-- PickiPedia:Compositions.
--
--
-- Each track records one subobject per player, because a subobject holds a set
-- Everything else reads that. A record page asks which compositions name it; a
-- of values and not a set of pairs -- "Harry Clark" and "mandolin" have to sit
-- musician's page asks which recordings name them. Neither keeps its own copy,
-- in the same subobject to stay attached to each other. One per player per
-- so a lineup is corrected in one place.
-- track is what keeps that true.
--
--
-- Note for future editors: do not wrap this header in a --[==[ ]==] block
-- Note for future editors: do not wrap this header in a --[==[ ]==] block
Line 16: Line 15:
local p = {}
local p = {}


-- Parameters of {{Studio track}} that describe the session rather than name a
-- Parameters of {{Studio version}} that describe the session rather than name
-- player. Anything else is a musician.
-- a player. Anything else is a musician.
local NOT_A_PLAYER = {
local NOT_A_PLAYER = {
track = true,
record = true,
number = true,
number = true,
studio = true,
studio = true,
recorded = true,
recorded = true,
engineer = true,
engineer = true,
record = true,
}
}


Line 44: Line 42:
--
--
-- #invoke gives the module only the arguments written in the invoke itself,
-- #invoke gives the module only the arguments written in the invoke itself,
-- which for a one-line wrapper like {{Studio track}} is none at all. The
-- which for a one-line wrapper is none at all; the template's own parameters
-- template's own parameters are on the parent frame. Reading frame.args
-- are on the parent frame. pairs(), not next(): Scribunto's args table is lazy,
-- directly is why the first version of this answered "no track name given" to
-- so next() reports no arguments on a call that has plenty.
-- a call that plainly gave one.
local function callArgs( frame )
local function callArgs( frame )
local parent = frame:getParent()
local parent = frame:getParent()
if parent then
if parent then
-- pairs(), not next(). Scribunto's args table is lazy: values arrive
-- through a metatable and the table itself is empty, so next() reports
-- no arguments on a call that has plenty.
for _ in pairs( parent.args ) do
for _ in pairs( parent.args ) do
return parent.args
return parent.args
Line 61: Line 55:
end
end


-- The players of a track.
-- Everything the parser wraps around a value that is not part of the value:
--
-- the strip marker, SMW's span markup, and SMW's own [[SMW::off]] switch,
-- Lua hands named arguments back in an arbitrary order, so they are sorted by
-- which brackets template-formatted results. Left in, a page name is not a
-- name: an order nobody chose is better than one that changes between parses.
-- name — "[[" .. it .. "]]" renders as a warning about a property label
-- containing "[".
local function plain( text )
text = mw.text.unstrip( text or "" )
text = text:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
return ( text:gsub( "%[%[SMW::o[nf]f?%]%]", "" ) )
end
 
-- A pipe or an equals sign would end a parameter early.
local function escape( value )
return ( value:gsub( "|", "{{!}}" ):gsub( "=", "{{=}}" ) )
end
 
-- The title without its namespace, for showing rather than linking.
local function bare( title )
return ( title:gsub( "^[^:]+:", "" ) )
end
 
local function players( args )
local function players( args )
local named = {}
local named = {}
Line 76: Line 87:
end
end


-- A pipe or an equals sign in a value would end the parameter early, so they
-- Record one player on one recording.
-- are written as the templates that stand for them.
local function escape( value )
return ( value:gsub( "|", "{{!}}" ):gsub( "=", "{{=}}" ) )
end
 
-- Everything the parser wraps around a value that is not part of the value.
--
-- Three layers, each found the hard way: the strip marker the parser hands
-- back, the span markup SMW adds even with link=none, and SMW's own
-- [[SMW::off]] switch, which brackets template-formatted results so a result
-- cannot annotate the page showing it. That last one made the first record's
-- name begin "[[SMW::off]]", so "[[" .. name .. "]]" rendered as a warning
-- about a property label containing "[" rather than as a link.
local function plain( text )
text = mw.text.unstrip( text or "" )
text = text:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
return ( text:gsub( "%[%[SMW::o[nf]f?%]%]", "" ) )
end
 
-- Record one player on one track, as data a query can reach.
--
--
-- Written as wikitext and run back through the preprocessor rather than called
-- Written as wikitext and run back through the preprocessor rather than called
-- with frame:callParserFunction. This wiki has no Semantic Scribunto, and a
-- with frame:callParserFunction: this wiki has no Semantic Scribunto, and a
-- #subobject invoked that way renders and is then forgotten: the page looked
-- #subobject invoked that way renders and is then forgotten.
-- right and [[Has performer::+]] matched nothing at all. Preprocessing it is
local function annotate( frame, record, number, player )
-- what actually stores it.
local function annotate( frame, track, number, player )
local parts = {
local parts = {
"Has track name=" .. escape( track ),
"Has record=" .. escape( record ),
"Has performer=" .. escape( player.name ),
"Has performer=" .. escape( player.name ),
}
}
Line 117: Line 106:
end
end


-- One track of a record: its personnel, recorded and shown.
-- One studio recording of this composition.
function p.track( frame )
function p.version( frame )
local args = callArgs( frame )
local args = callArgs( frame )
local track = trim( args.track )
local record = trim( args.record )
if track == "" then
if record == "" then
return '<span class="error">Studio track: no track name given</span>'
return '<span class="error">Studio version: no record given</span>'
end
end
local number = trim( args.number )
local number = trim( args.number )


local named = players( args )
local shown = {}
local shown = {}
for _, player in ipairs( named ) do
for _, player in ipairs( players( args ) ) do
annotate( frame, track, number, player )
annotate( frame, record, number, player )


-- The icon comes from the instrument played here, not from whatever the
-- The icon comes from the instrument played here, not from whatever
-- musician's page calls their main one: a guitarist who played dobro on
-- the musician's page calls their main one: a guitarist who played
-- one track played dobro on that track.
-- dobro on this session played dobro.
local first = split( player.instruments )[1] or ""
local first = split( player.instruments )[1] or ""
local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
Line 142: Line 130:
end
end


local label = number ~= "" and ( number .. ". " .. track ) or track
local session = {}
local session = {}
for _, field in ipairs( { "recorded", "studio", "engineer" } ) do
for _, field in ipairs( { "recorded", "studio", "engineer" } ) do
Line 152: Line 139:


local out = { '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">' }
local out = { '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">' }
table.insert( out, '<div style="font-weight:600;">' .. label .. "</div>" )
table.insert( out, '<div style="font-weight:600;">[[' .. record .. "]]"
.. ( number ~= "" and ( ' <span style="font-weight:400; color:#6b7566;">track '
.. number .. "</span>" ) or "" ) .. "</div>" )
if #shown > 0 then
if #shown > 0 then
table.insert( out, '<div style="font-size:.95em; line-height:1.9;">'
table.insert( out, '<div style="font-size:.95em; line-height:1.9;">'
Line 165: Line 154:
end
end


-- One property of one page, as plain text. Asked of the record's own page, so
-- One property of one page, as plain text.
-- that the licence and the credited artist live where the record is described
-- rather than being repeated on every track.
local function property( frame, page, name )
local function property( frame, page, name )
return trim( plain( frame:callParserFunction( "#show", {
return trim( plain( frame:callParserFunction( "#show", {
Line 183: Line 170:
end
end


-- Every track a musician appears on, grouped by record.
-- Rows of "first||second||third||fourth", from Template:Studio appearance row.
local function rows( frame, query, printouts, mainlabel )
local args = { query }
for _, printout in ipairs( printouts ) do
table.insert( args, printout )
end
for _, option in ipairs( {
"mainlabel=" .. mainlabel, "format=plainlist", "link=none", "sep=@@",
"template=Studio appearance row", "searchlabel=", "limit=500",
} ) do
table.insert( args, option )
end
 
local raw = plain( frame:callParserFunction( "#ask", args ) )
local out = {}
if trim( raw ) == "" then
return out
end
for _, row in ipairs( mw.text.split( raw, "@@", true ) ) do
local a, b, c, d = row:match( "^(.-)||(.-)||(.-)||(.*)$" )
if a and trim( a ) ~= "" then
-- A subobject's subject prints as "Song:Barlows#_abc123"; the page
-- is the part before the fragment.
table.insert( out, {
subject = trim( ( a:match( "^([^#]*)" ) or a ) ),
b = trim( b ), c = trim( c ), d = trim( d ),
} )
end
end
return out
end
 
local function subjectOf( frame )
local given = trim( callArgs( frame )[1] or frame.args[1] )
if given ~= "" then
return given
end
return mw.title.getCurrentTitle().fullText
end
 
-- Every recording a musician played on, grouped by record.
--
--
-- @param frame
-- @param wanted "free" for records anybody may share, "rest" for the others.
-- @param wanted "free" for records anybody may share, "rest" for the others.
-- @param heading the section heading to put above them.
local function appearances( frame, wanted, heading )
local function listing( frame, wanted, heading )
local who = subjectOf( frame )
local who = trim( callArgs( frame )[1] or frame.args[1] )
local found = rows( frame, "[[Has performer::" .. who .. "]]", {
if who == "" then
"?Has record=record",
who = mw.title.getCurrentTitle().text
end
 
local raw = plain( frame:callParserFunction( "#ask", {
"[[Has performer::" .. who .. "]]",
"?Has track name=track",
"?Has track number=number",
"?Has track number=number",
"?Has instrument=instrument",
"?Has instrument=instrument",
"mainlabel=record",
}, "song" )
"format=plainlist",
"link=none",
"sep=@@",
"template=Studio appearance row",
"searchlabel=",
"limit=200",
} ) )
if trim( raw ) == "" then
return ""
end


-- Rows arrive as "record||track||number||instrument" from
-- Template:Studio appearance row. Grouping happens here rather than in the
-- query because a record with six tracks should read as one line, not six.
local order, byRecord = {}, {}
local order, byRecord = {}, {}
for _, row in ipairs( mw.text.split( raw, "@@", true ) ) do
for _, row in ipairs( found ) do
local record, track, number, instrument = row:match( "^(.-)||(.-)||(.-)||(.*)$" )
local record = row.b
if record and trim( record ) ~= "" then
if record ~= "" then
-- A subobject's subject prints as "4masks#_abc123". The record is
-- the part before the fragment; the fragment is SMW's own id.
record = trim( record:match( "^([^#]*)" ) or record )
if not byRecord[ record ] then
if not byRecord[ record ] then
byRecord[ record ] = { tracks = {}, instruments = {}, seen = {} }
byRecord[ record ] = { songs = {}, instruments = {}, seen = {} }
table.insert( order, record )
table.insert( order, record )
end
end
local entry = byRecord[ record ]
local entry = byRecord[ record ]
if trim( track ) ~= "" and not entry.seen[ track ] then
if not entry.seen[ row.subject ] then
entry.seen[ track ] = true
entry.seen[ row.subject ] = true
table.insert( entry.tracks, { title = trim( track ), number = tonumber( number ) } )
table.insert( entry.songs,
{ title = row.subject, number = tonumber( row.c ) } )
end
end
for _, one in ipairs( split( instrument ) ) do
for _, one in ipairs( split( row.d ) ) do
if not entry.instruments[ one ] then
if not entry.instruments[ one ] then
entry.instruments[ one ] = true
entry.instruments[ one ] = true
Line 242: Line 247:
for _, record in ipairs( order ) do
for _, record in ipairs( order ) do
local entry = byRecord[ record ]
local entry = byRecord[ record ]
table.sort( entry.tracks, function( a, b )
local isFree, licence = freelyLicensed( frame, record )
if a.number and b.number then return a.number < b.number end
if ( wanted == "free" ) == isFree then
return a.title < b.title
table.sort( entry.songs, function( a, b )
end )
if a.number and b.number then return a.number < b.number end
return a.title < b.title
end )


local titles = {}
local titles = {}
for _, track in ipairs( entry.tracks ) do
for _, song in ipairs( entry.songs ) do
table.insert( titles, track.title )
table.insert( titles, "[[" .. song.title .. "|" .. bare( song.title ) .. "]]" )
end
end


local isFree, licence = freelyLicensed( frame, record )
if ( wanted == "free" ) == isFree then
local artist = property( frame, record, "Has record artist" )
local artist = property( frame, record, "Has record artist" )
local instruments = table.concat( entry.instruments, ", " )
local line = {
local line = {
'<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">',
'<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">',
'<div style="font-weight:600;">[[' .. record .. "]]",
'<div style="font-weight:600;">[[' .. record .. "]]",
}
}
-- Whose record it is. A picker's page should say whose session
-- they played on without making the reader open the record.
if artist ~= "" then
if artist ~= "" then
table.insert( line, ' <span style="font-weight:400; color:#5a6858;">by [['
table.insert( line, ' <span style="font-weight:400; color:#5a6858;">by [['
Line 273: Line 275:
table.insert( line, "</div>" )
table.insert( line, "</div>" )
table.insert( line, '<div style="font-size:.9em; color:#3d4a3e;">'
table.insert( line, '<div style="font-size:.9em; color:#3d4a3e;">'
.. ( instruments ~= "" and instruments .. " &middot; " or "" )
.. ( #entry.instruments > 0
.. ( #titles == 1 and titles[1] or ( #titles .. " tracks: " .. table.concat( titles, ", " ) ) )
and ( table.concat( entry.instruments, ", " ) .. " &middot; " ) or "" )
.. "</div>" )
.. table.concat( titles, ", " ) .. "</div>" )
table.insert( line, "</div>" )
table.insert( line, "</div>" )
table.insert( blocks, table.concat( line, "" ) )
table.insert( blocks, table.concat( line, "" ) )
end
end
Line 285: Line 286:
return ""
return ""
end
end
table.insert( blocks, 1, "== " .. heading .. " ==" )
table.insert( blocks, 1, "== " .. heading .. " ==" )
return table.concat( blocks, "\n" )
return table.concat( blocks, "\n" )
end
end


-- The records a reader may share, which is what this wiki is for.
function p.appearances( frame )
function p.appearances( frame )
return listing( frame, "free",
return appearances( frame, "free",
"[[PickiPedia:Creative Commons|Creative Commons]] Studio Work" )
"[[PickiPedia:Creative Commons|Creative Commons]] Studio Work" )
end
end


-- Everything else the picker played on. Worth recording, and kept out of the
-- section above, which is a promise about what a reader can do with what they
-- find there.
function p.other( frame )
function p.other( frame )
return listing( frame, "rest", "Other studio work" )
return appearances( frame, "rest", "Other studio work" )
end
 
-- The track listing of a record, assembled from the compositions that name it.
--
-- The record page keeps no list of its own. A track is a recording, recordings
-- live on the composition's page, and a page that copied them here would be
-- one more thing to keep in step.
function p.tracks( frame )
local record = subjectOf( frame )
local found = rows( frame, "[[Has record::" .. record .. "]]", {
"?Has track number=number",
"?Has performer=performer",
"?Has instrument=instrument",
}, "song" )
 
local order, bySong = {}, {}
for _, row in ipairs( found ) do
if not bySong[ row.subject ] then
bySong[ row.subject ] = { number = tonumber( row.b ), players = {}, seen = {} }
table.insert( order, row.subject )
end
local entry = bySong[ row.subject ]
entry.number = entry.number or tonumber( row.b )
if row.c ~= "" and not entry.seen[ row.c ] then
entry.seen[ row.c ] = true
table.insert( entry.players, { name = row.c, instrument = split( row.d )[1] or "" } )
end
end
 
if #order == 0 then
return ""
end
 
table.sort( order, function( a, b )
local na, nb = bySong[ a ].number, bySong[ b ].number
if na and nb then return na < nb end
if na then return true end
if nb then return false end
return a < b
end )
 
local out = {}
for _, song in ipairs( order ) do
local entry = bySong[ song ]
table.sort( entry.players, function( a, b ) return a.name < b.name end )
 
local shown = {}
for _, player in ipairs( entry.players ) do
table.insert( shown, frame:expandTemplate{
title = "m", args = { player.name, player.instrument } } )
end
 
local line = { '<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">' }
table.insert( line, '<div style="font-weight:600;">'
.. ( entry.number and ( entry.number .. ". " ) or "" )
.. "[[" .. song .. "|" .. bare( song ) .. "]]</div>" )
if #shown > 0 then
table.insert( line, '<div style="font-size:.9em; line-height:1.9; color:#3d4a3e;">'
.. table.concat( shown, " &middot; " ) .. "</div>" )
end
table.insert( line, "</div>" )
table.insert( out, table.concat( line, "" ) )
end
return table.concat( out, "\n" )
end
 
-- Deprecated: the previous shape, where recordings lived on the record's page
-- and named their track by title.
--
-- Kept only so the three record pages written that way keep rendering until
-- the importer has moved them onto composition pages. Nothing should call it
-- afterwards; see PickiPedia:Compositions for why the data moved.
function p.track( frame )
local args = callArgs( frame )
local track = trim( args.track )
if track == "" then
return '<span class="error">Studio track: no track name given</span>'
end
local number = trim( args.number )
 
local shown = {}
for _, player in ipairs( players( args ) ) do
local parts = {
"Has track name=" .. escape( track ),
"Has performer=" .. escape( player.name ),
}
if number ~= "" then
table.insert( parts, "Has track number=" .. escape( number ) )
end
for _, instrument in ipairs( split( player.instruments ) ) do
table.insert( parts, "Has instrument=" .. escape( instrument ) )
end
frame:preprocess( "{{#subobject:|" .. table.concat( parts, "|" ) .. "}}" )
 
local first = split( player.instruments )[1] or ""
local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
if player.instruments ~= "" then
link = link .. ' <span style="color:#6b7566;">' .. player.instruments .. "</span>"
end
table.insert( shown, link )
end
 
local label = number ~= "" and ( number .. ". " .. track ) or track
return '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">'
.. '<div style="font-weight:600;">' .. label .. "</div>"
.. '<div style="font-size:.95em; line-height:1.9;">'
.. table.concat( shown, " &middot; " ) .. "</div></div>"
end
end


return p
return p

Revision as of 23:42, 18 September 2026

Documentation for this module may be created at Module:Studio sessions/doc

-- Studio recordings: who played what, on which recording of which composition.
--
-- The composition is the page. A recording of it is a subobject on that page,
-- naming the record it was cut for and everyone who played on it. See
-- PickiPedia:Compositions.
--
-- Everything else reads that. A record page asks which compositions name it; a
-- musician's page asks which recordings name them. Neither keeps its own copy,
-- so a lineup is corrected in one place.
--
-- Note for future editors: do not wrap this header in a --[==[ ]==] block
-- comment containing wiki link syntax. Lua reads the inner brackets as nested
-- long brackets and refuses to load the module.

local p = {}

-- Parameters of {{Studio version}} that describe the session rather than name
-- a player. Anything else is a musician.
local NOT_A_PLAYER = {
	record = true,
	number = true,
	studio = true,
	recorded = true,
	engineer = true,
}

local function trim( s )
	return ( s or "" ):gsub( "^%s*(.-)%s*$", "%1" )
end

local function split( raw )
	local out = {}
	for part in ( ( raw or "" ) .. "," ):gmatch( "%s*(.-)%s*," ) do
		if part ~= "" then
			table.insert( out, part )
		end
	end
	return out
end

-- The arguments of the template that invoked us.
--
-- #invoke gives the module only the arguments written in the invoke itself,
-- which for a one-line wrapper is none at all; the template's own parameters
-- are on the parent frame. pairs(), not next(): Scribunto's args table is lazy,
-- so next() reports no arguments on a call that has plenty.
local function callArgs( frame )
	local parent = frame:getParent()
	if parent then
		for _ in pairs( parent.args ) do
			return parent.args
		end
	end
	return frame.args
end

-- Everything the parser wraps around a value that is not part of the value:
-- the strip marker, SMW's span markup, and SMW's own [[SMW::off]] switch,
-- which brackets template-formatted results. Left in, a page name is not a
-- name — "[[" .. it .. "]]" renders as a warning about a property label
-- containing "[".
local function plain( text )
	text = mw.text.unstrip( text or "" )
	text = text:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
	return ( text:gsub( "%[%[SMW::o[nf]f?%]%]", "" ) )
end

-- A pipe or an equals sign would end a parameter early.
local function escape( value )
	return ( value:gsub( "|", "{{!}}" ):gsub( "=", "{{=}}" ) )
end

-- The title without its namespace, for showing rather than linking.
local function bare( title )
	return ( title:gsub( "^[^:]+:", "" ) )
end

local function players( args )
	local named = {}
	for name, value in pairs( args ) do
		if type( name ) == "string" and not NOT_A_PLAYER[ name ] then
			table.insert( named, { name = trim( name ), instruments = trim( value ) } )
		end
	end
	table.sort( named, function( a, b ) return a.name < b.name end )
	return named
end

-- Record one player on one recording.
--
-- Written as wikitext and run back through the preprocessor rather than called
-- with frame:callParserFunction: this wiki has no Semantic Scribunto, and a
-- #subobject invoked that way renders and is then forgotten.
local function annotate( frame, record, number, player )
	local parts = {
		"Has record=" .. escape( record ),
		"Has performer=" .. escape( player.name ),
	}
	if number ~= "" then
		table.insert( parts, "Has track number=" .. escape( number ) )
	end
	for _, instrument in ipairs( split( player.instruments ) ) do
		table.insert( parts, "Has instrument=" .. escape( instrument ) )
	end
	frame:preprocess( "{{#subobject:|" .. table.concat( parts, "|" ) .. "}}" )
end

-- One studio recording of this composition.
function p.version( frame )
	local args = callArgs( frame )
	local record = trim( args.record )
	if record == "" then
		return '<span class="error">Studio version: no record given</span>'
	end
	local number = trim( args.number )

	local shown = {}
	for _, player in ipairs( players( args ) ) do
		annotate( frame, record, number, player )

		-- The icon comes from the instrument played here, not from whatever
		-- the musician's page calls their main one: a guitarist who played
		-- dobro on this session played dobro.
		local first = split( player.instruments )[1] or ""
		local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
		if player.instruments ~= "" then
			link = link .. ' <span style="color:#6b7566;">' .. player.instruments .. "</span>"
		end
		table.insert( shown, link )
	end

	local session = {}
	for _, field in ipairs( { "recorded", "studio", "engineer" } ) do
		local value = trim( args[ field ] )
		if value ~= "" then
			table.insert( session, field == "engineer" and ( "engineer " .. value ) or value )
		end
	end

	local out = { '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">' }
	table.insert( out, '<div style="font-weight:600;">[[' .. record .. "]]"
		.. ( number ~= "" and ( ' <span style="font-weight:400; color:#6b7566;">track '
			.. number .. "</span>" ) or "" ) .. "</div>" )
	if #shown > 0 then
		table.insert( out, '<div style="font-size:.95em; line-height:1.9;">'
			.. table.concat( shown, " &middot; " ) .. "</div>" )
	end
	if #session > 0 then
		table.insert( out, '<div style="font-size:.8em; color:#6b7566;">'
			.. table.concat( session, " &middot; " ) .. "</div>" )
	end
	table.insert( out, "</div>" )
	return table.concat( out, "\n" )
end

-- One property of one page, as plain text.
local function property( frame, page, name )
	return trim( plain( frame:callParserFunction( "#show", {
		page, "?" .. name, "link=none", "default=",
	} ) ) )
end

-- Whether a record is under a licence that lets people share it.
local function freelyLicensed( frame, record )
	local licence = property( frame, record, "Has license" )
	local lower = licence:lower()
	local free = lower:find( "cc " ) or lower:find( "creative commons" )
		or lower:find( "cc0" ) or lower:find( "public domain" )
	return ( free ~= nil ), licence
end

-- Rows of "first||second||third||fourth", from Template:Studio appearance row.
local function rows( frame, query, printouts, mainlabel )
	local args = { query }
	for _, printout in ipairs( printouts ) do
		table.insert( args, printout )
	end
	for _, option in ipairs( {
		"mainlabel=" .. mainlabel, "format=plainlist", "link=none", "sep=@@",
		"template=Studio appearance row", "searchlabel=", "limit=500",
	} ) do
		table.insert( args, option )
	end

	local raw = plain( frame:callParserFunction( "#ask", args ) )
	local out = {}
	if trim( raw ) == "" then
		return out
	end
	for _, row in ipairs( mw.text.split( raw, "@@", true ) ) do
		local a, b, c, d = row:match( "^(.-)||(.-)||(.-)||(.*)$" )
		if a and trim( a ) ~= "" then
			-- A subobject's subject prints as "Song:Barlows#_abc123"; the page
			-- is the part before the fragment.
			table.insert( out, {
				subject = trim( ( a:match( "^([^#]*)" ) or a ) ),
				b = trim( b ), c = trim( c ), d = trim( d ),
			} )
		end
	end
	return out
end

local function subjectOf( frame )
	local given = trim( callArgs( frame )[1] or frame.args[1] )
	if given ~= "" then
		return given
	end
	return mw.title.getCurrentTitle().fullText
end

-- Every recording a musician played on, grouped by record.
--
-- @param wanted "free" for records anybody may share, "rest" for the others.
local function appearances( frame, wanted, heading )
	local who = subjectOf( frame )
	local found = rows( frame, "[[Has performer::" .. who .. "]]", {
		"?Has record=record",
		"?Has track number=number",
		"?Has instrument=instrument",
	}, "song" )

	local order, byRecord = {}, {}
	for _, row in ipairs( found ) do
		local record = row.b
		if record ~= "" then
			if not byRecord[ record ] then
				byRecord[ record ] = { songs = {}, instruments = {}, seen = {} }
				table.insert( order, record )
			end
			local entry = byRecord[ record ]
			if not entry.seen[ row.subject ] then
				entry.seen[ row.subject ] = true
				table.insert( entry.songs,
					{ title = row.subject, number = tonumber( row.c ) } )
			end
			for _, one in ipairs( split( row.d ) ) do
				if not entry.instruments[ one ] then
					entry.instruments[ one ] = true
					table.insert( entry.instruments, one )
				end
			end
		end
	end

	local blocks = {}
	for _, record in ipairs( order ) do
		local entry = byRecord[ record ]
		local isFree, licence = freelyLicensed( frame, record )
		if ( wanted == "free" ) == isFree then
			table.sort( entry.songs, function( a, b )
				if a.number and b.number then return a.number < b.number end
				return a.title < b.title
			end )

			local titles = {}
			for _, song in ipairs( entry.songs ) do
				table.insert( titles, "[[" .. song.title .. "|" .. bare( song.title ) .. "]]" )
			end

			local artist = property( frame, record, "Has record artist" )
			local line = {
				'<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">',
				'<div style="font-weight:600;">[[' .. record .. "]]",
			}
			if artist ~= "" then
				table.insert( line, ' <span style="font-weight:400; color:#5a6858;">by [['
					.. artist .. "]]</span>" )
			end
			if isFree and licence ~= "" then
				table.insert( line, ' <span style="font-size:.72em; letter-spacing:.08em;'
					.. ' text-transform:uppercase; color:#3d6b3d; border:1px solid #9bb89b;'
					.. ' border-radius:3px; padding:0 .35em;">' .. licence .. "</span>" )
			end
			table.insert( line, "</div>" )
			table.insert( line, '<div style="font-size:.9em; color:#3d4a3e;">'
				.. ( #entry.instruments > 0
					and ( table.concat( entry.instruments, ", " ) .. " &middot; " ) or "" )
				.. table.concat( titles, ", " ) .. "</div>" )
			table.insert( line, "</div>" )
			table.insert( blocks, table.concat( line, "" ) )
		end
	end

	if #blocks == 0 then
		return ""
	end
	table.insert( blocks, 1, "== " .. heading .. " ==" )
	return table.concat( blocks, "\n" )
end

function p.appearances( frame )
	return appearances( frame, "free",
		"[[PickiPedia:Creative Commons|Creative Commons]] Studio Work" )
end

function p.other( frame )
	return appearances( frame, "rest", "Other studio work" )
end

-- The track listing of a record, assembled from the compositions that name it.
--
-- The record page keeps no list of its own. A track is a recording, recordings
-- live on the composition's page, and a page that copied them here would be
-- one more thing to keep in step.
function p.tracks( frame )
	local record = subjectOf( frame )
	local found = rows( frame, "[[Has record::" .. record .. "]]", {
		"?Has track number=number",
		"?Has performer=performer",
		"?Has instrument=instrument",
	}, "song" )

	local order, bySong = {}, {}
	for _, row in ipairs( found ) do
		if not bySong[ row.subject ] then
			bySong[ row.subject ] = { number = tonumber( row.b ), players = {}, seen = {} }
			table.insert( order, row.subject )
		end
		local entry = bySong[ row.subject ]
		entry.number = entry.number or tonumber( row.b )
		if row.c ~= "" and not entry.seen[ row.c ] then
			entry.seen[ row.c ] = true
			table.insert( entry.players, { name = row.c, instrument = split( row.d )[1] or "" } )
		end
	end

	if #order == 0 then
		return ""
	end

	table.sort( order, function( a, b )
		local na, nb = bySong[ a ].number, bySong[ b ].number
		if na and nb then return na < nb end
		if na then return true end
		if nb then return false end
		return a < b
	end )

	local out = {}
	for _, song in ipairs( order ) do
		local entry = bySong[ song ]
		table.sort( entry.players, function( a, b ) return a.name < b.name end )

		local shown = {}
		for _, player in ipairs( entry.players ) do
			table.insert( shown, frame:expandTemplate{
				title = "m", args = { player.name, player.instrument } } )
		end

		local line = { '<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">' }
		table.insert( line, '<div style="font-weight:600;">'
			.. ( entry.number and ( entry.number .. ". " ) or "" )
			.. "[[" .. song .. "|" .. bare( song ) .. "]]</div>" )
		if #shown > 0 then
			table.insert( line, '<div style="font-size:.9em; line-height:1.9; color:#3d4a3e;">'
				.. table.concat( shown, " &middot; " ) .. "</div>" )
		end
		table.insert( line, "</div>" )
		table.insert( out, table.concat( line, "" ) )
	end
	return table.concat( out, "\n" )
end

-- Deprecated: the previous shape, where recordings lived on the record's page
-- and named their track by title.
--
-- Kept only so the three record pages written that way keep rendering until
-- the importer has moved them onto composition pages. Nothing should call it
-- afterwards; see PickiPedia:Compositions for why the data moved.
function p.track( frame )
	local args = callArgs( frame )
	local track = trim( args.track )
	if track == "" then
		return '<span class="error">Studio track: no track name given</span>'
	end
	local number = trim( args.number )

	local shown = {}
	for _, player in ipairs( players( args ) ) do
		local parts = {
			"Has track name=" .. escape( track ),
			"Has performer=" .. escape( player.name ),
		}
		if number ~= "" then
			table.insert( parts, "Has track number=" .. escape( number ) )
		end
		for _, instrument in ipairs( split( player.instruments ) ) do
			table.insert( parts, "Has instrument=" .. escape( instrument ) )
		end
		frame:preprocess( "{{#subobject:|" .. table.concat( parts, "|" ) .. "}}" )

		local first = split( player.instruments )[1] or ""
		local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
		if player.instruments ~= "" then
			link = link .. ' <span style="color:#6b7566;">' .. player.instruments .. "</span>"
		end
		table.insert( shown, link )
	end

	local label = number ~= "" and ( number .. ". " .. track ) or track
	return '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">'
		.. '<div style="font-weight:600;">' .. label .. "</div>"
		.. '<div style="font-size:.95em; line-height:1.9;">'
		.. table.concat( shown, " &middot; " ) .. "</div></div>"
end

return p