2 ### -*- mode: python; coding: utf-8 -*-
4 from contextlib
import contextmanager
9 class ExpectedError (Exception): pass
18 def filter(value
, func
= None, dflt
= None):
19 if value
is None: return dflt
20 elif func
is None: return value
21 else: return func(value
)
24 if not cond
: raise ExpectedError(msg
)
27 if not s
.isdigit(): raise ExpectedError("bad integer `%s'" % s
)
33 me
._i
, me
._n
= 0, len(s
)
35 s
, i
, n
= me
._s
, me
._i
, me
._n
37 if not s
[i
].isspace(): return i
42 begin
= i
= me
._wordstart()
43 if begin
< 0: return None
44 while i
< n
and not s
[i
].isspace(): i
+= 1
49 begin
= me
._wordstart()
50 if begin
< 0: return None
51 else: return s
[begin
:].rstrip()
53 URL_SAFE_P
= 256*[False]
55 b
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
56 b
"abcdefghijklmnopqrstuvwxyz" \
57 b
"0123456789" b
"!$%-.,/":
60 return "".join((URL_SAFE_P
[ch
] and chr(ch
) or "%%%02x" % ch
61 for ch
in s
.encode("UTF-8")))
63 PROG
= OS
.path
.basename(SYS
.argv
[0])
65 class BaseLocation (object):
67 SYS
.stderr
.write("%s: %s%s\n" %
(PROG
, me
._loc(), exc
))
69 class DummyLocation (BaseLocation
):
70 def _loc(me
): return ""
72 class FileLocation (BaseLocation
):
73 def __init__(me
, fn
, lno
= 1): me
._fn
, me
._lno
= fn
, lno
74 def _loc(me
): return "%s:%d: " %
(me
._fn
, me
._lno
)
75 def stepline(me
): me
._lno
+= 1
79 class Source (object):
81 TITLEP
= CHAPTERP
= False
84 def url(me
, title
= None, chapter
= None):
86 if me
.TITLEP
: raise ExpectedError("missing title number")
87 if chapter
is not None:
88 raise ExpectedError("can't specify chapter without title")
91 raise ExpectedError("can't specify title with `%s'" % me
.fn
)
93 suffix
= "#%d" % title
95 raise ExpectedError("can't specify chapter with `%s'" % me
.fn
)
97 suffix
= "#%d:%d-%d:%d" %
(title
, chapter
, title
, chapter
)
98 return me
.PREFIX
+ ROOT
+ urlencode(me
.fn
) + suffix
100 class VideoDisc (Source
):
102 TITLEP
= CHAPTERP
= True
104 class VideoEpisodes (VideoDisc
):
105 def __init__(me
, fn
, season
, eps
, *args
, **kw
):
106 super().__init__(fn
, *args
, **kw
)
110 class VideoSeason (object):
111 def __init__(me
, i
, title
):
115 def add_disc(me
, fn
, eps
):
116 d
= VideoEpisodes(fn
, me
, eps
)
119 raise ExpectedError("season %d episode %d already taken" %
(me
.i
, i
))
123 def some_group(m
, *gg
):
126 if s
is not None: return s
129 class VideoDir (object):
131 _R_ISO_PRE
= RX
.compile(r
"""
133 (?: S (?P<si> \d+) (?: \. \ (?P<st> .*)—)? (?: D (?P<sdi> \d+))? |
140 _R_ISO_EP
= RX
.compile(r
"""
141 ^ E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
144 def __init__(me
, dir):
145 fns
= OS
.listdir(OS
.path
.join(ROOT
, dir))
147 season
, last_j
= None, 0
150 path
= OS
.path
.join(dir, fn
)
151 if not fn
.endswith(".iso"): continue
152 m
= me
._R_ISO_PRE
.match(fn
)
155 i
= filter(m
.group("si"), int, 1)
156 stitle
= m
.group("st")
157 if season
is None or i
!= season
.i
:
158 check(season
is None or i
== season
.i
+ 1,
159 "season %d /= %d" %
(i
, season
is None and -1 or season
.i
+ 1))
160 check(i
not in seasons
, "season %d already seen" % i
)
161 seasons
[i
] = season
= VideoSeason(i
, stitle
)
164 check(stitle
== season
.title
,
165 "season title `%s' /= `%s'" %
(stitle
, season
.title
))
166 j
= filter(some_group(m
, "sdi", "di"), int)
168 check(j
== last_j
+ 1,
169 "season %d disc %d /= %d" %
(season
.i
, j
, last_j
+ 1))
173 for eprange
in m
.group("eps").split(", "):
174 mm
= me
._R_ISO_EP
.match(eprange
)
175 if mm
is None: bad
= True; continue
176 start
= filter(mm
.group("ei"), int)
177 end
= filter(mm
.group("ej"), int, start
)
178 for k
in range(start
, end
+ 1): eps
.add(k
)
180 raise ExpectedError("bad ep list in `%s'", fn
)
181 season
.add_disc(path
, eps
)
185 class AudioDisc (Source
):
187 TITLEP
= CHAPTERP
= False
189 class AudioEpisode (Source
):
191 TITLEP
= CHAPTERP
= False
192 def __init__(me
, fn
, i
, *args
, **kw
):
193 super().__init__(fn
, *args
, **kw
)
196 class AudioDir (object):
198 _R_FLAC
= RX
.compile(r
"""
205 def __init__(me
, dir):
206 fns
= OS
.listdir(OS
.path
.join(ROOT
, dir))
211 path
= OS
.path
.join(dir, fn
)
212 if not fn
.endswith(".flac"): continue
213 m
= me
._R_FLAC
.match(fn
)
215 i
= filter(m
.group(1), int)
217 check(i
== last_i
+ 1, "episode %d /= %d" %
(i
, last_i
+ 1))
218 episodes
[i
] = AudioEpisode(path
, i
)
220 me
.episodes
= episodes
222 class Chapter (object):
223 def __init__(me
, episode
, title
, i
):
224 me
.title
, me
.i
= title
, i
225 me
.url
= episode
.source
.url(episode
.tno
, i
)
227 class Episode (object):
228 def __init__(me
, season
, i
, neps
, title
, src
, tno
= None):
230 me
.i
, me
.neps
, me
.title
= i
, neps
, title
232 me
.source
, me
.tno
= src
, tno
233 me
.url
= src
.url(tno
)
234 def add_chapter(me
, title
, j
):
235 ch
= Chapter(me
, title
, j
)
236 me
.chapters
.append(ch
)
239 return me
.season
._eplabel(me
.i
, me
.neps
, me
.title
)
241 class Season (object):
242 def __init__(me
, playlist
, title
, i
, implicitp
= False):
243 me
.playlist
= playlist
244 me
.title
, me
.i
= title
, i
245 me
.implicitp
= implicitp
247 def add_episode(me
, j
, neps
, title
, src
, tno
):
248 ep
= Episode(me
, j
, neps
, title
, src
, tno
)
249 me
.episodes
.append(ep
)
251 def _eplabel(me
, i
, neps
, title
):
252 if neps
== 1: epname
= me
.playlist
.epname
; epn
= "%d" % i
253 elif neps
== 2: epname
= me
.playlist
.epnames
; epn
= "%d, %d" %
(i
, i
+ 1)
254 else: epname
= me
.playlist
.epnames
; epn
= "%d–%d" %
(i
, i
+ neps
- 1)
256 if me
.implicitp
: return "%s %s" %
(epname
, epn
)
257 elif me
.title
is None: return "%s %d.%s" %
(epname
, me
.i
, epn
)
258 else: return "%s—%s %s" %
(me
.title
, epname
, epn
)
260 if me
.implicitp
: return "%s. %s" %
(epn
, title
)
261 elif me
.title
is None: return "%d.%s. %s" %
(me
.i
, epn
, title
)
262 else: return "%s—%s. %s" %
(me
.title
, epn
, title
)
264 class MovieSeason (object):
265 def __init__(me
, playlist
):
266 me
.playlist
= playlist
270 def add_episode(me
, j
, neps
, title
, src
, tno
):
271 if title
is None: raise ExpectedError("movie must have a title")
272 ep
= Episode(me
, j
, neps
, title
, src
, tno
)
273 me
.episodes
.append(ep
)
275 def _eplabel(me
, i
, epn
, title
):
278 class Playlist (object):
282 me
.epname
, me
.epnames
= "Episode", "Episodes"
284 def add_season(me
, title
, i
, implicitp
= False):
285 season
= Season(me
, title
, i
, implicitp
)
286 me
.seasons
.append(season
)
290 season
= MovieSeason(me
)
291 me
.seasons
.append(season
)
296 for season
in me
.seasons
:
298 for i
, ep
in enumerate(season
.episodes
, 1):
300 f
.write("#EXTINF:0,,%s\n%s\n" %
(ep
.label(), ep
.url
))
302 for ch
in ep
.chapters
:
303 f
.write("#EXTINF:0,,%s: %s\n%s\n" %
304 (ep
.label(), ch
.title
, ch
.url
))
309 playlist
= Playlist()
310 season
, episode
, chapter
, ep_i
= None, None, None, 1
313 with
location(FileLocation(fn
, 0)) as floc
:
314 with
open(fn
, "r") as f
:
317 sline
= line
.lstrip()
318 if sline
== "" or sline
.startswith(";"): continue
320 if line
.startswith("!"):
323 check(cmd
is not None, "missing command")
327 check(v
is not None, "missing season number")
329 check(v
.rest() is None, "trailing junk")
330 season
= playlist
.add_movies()
334 season
= playlist
.add_season(title
, i
, implicitp
= False)
335 episode
= chapter
= None
339 check(ww
.rest() is None, "trailing junk")
340 season
= playlist
.add_movies()
341 episode
= chapter
= None
344 elif cmd
== "epname":
346 check(name
is not None, "missing episode name")
347 try: sep
= name
.index(":")
348 except ValueError: names
= name
+ "s"
349 else: name
, names
= name
[:sep
], name
[sep
+ 1:]
350 playlist
.epname
, playlist
.epnames
= name
, names
354 check(i
is not None, "missing episode number")
358 fn
= ww
.rest(); check(fn
is not None, "missing filename")
359 if fn
== "-": iso
= None
361 check(OS
.path
.exists(OS
.path
.join(ROOT
, fn
)),
362 "iso file `%s' not found" % fn
)
366 name
= ww
.nextword(); check(name
is not None, "missing name")
367 fn
= ww
.rest(); check(fn
is not None, "missing directory")
370 except KeyError: pass
372 vds
[name
] = VideoDir(fn
)
375 fn
= ww
.rest(); check(fn
is not None, "missing directory")
376 if fn
== "-": ads
= None
377 else: ads
= AudioDir(fn
)
383 raise ExpectedError("unknown command `%s'" % cmd
)
387 if not line
[0].isspace():
391 check(conf
is not None, "missing config")
392 i
, vdname
, neps
, fake_epi
= UNSET
, "-", 1, ep_i
393 for c
in conf
.split(","):
394 if c
.isdigit(): i
= int(c
)
395 elif c
== "-": i
= None
397 eq
= c
.find("="); check(eq
>= 0, "bad assignment `%s'" % c
)
398 k
, v
= c
[:eq
], c
[eq
+ 1:]
399 if k
== "vd": vdname
= v
400 elif k
== "n": neps
= getint(v
)
401 elif k
== "ep": fake_epi
= getint(v
)
402 else: raise ExpectedError("unknown setting `%s'" % k
)
405 check(i
is not UNSET
, "no title number")
407 season
= playlist
.add_season(None, 1, implicitp
= True)
410 check(ads
, "no title, but no audio directory")
411 check(season
.implicitp
, "audio source, but explicit season")
412 src
= ads
.episodes
[ep_i
]
418 check(vdname
in vds
, "title, but no iso or video directory")
419 src
= vds
[vdname
].seasons
[season
.i
].episodes
[ep_i
]
421 episode
= season
.add_episode(fake_epi
, neps
, title
, src
, i
)
428 check(episode
is not None, "no current episode")
429 check(episode
.source
.CHAPTERP
,
430 "episode source doesn't allow chapters")
431 if chapter
is None: j
= 1
433 chapter
= episode
.add_chapter(title
, j
)
437 ROOT
= "/mnt/dvd/archive/"
440 for f
in SYS
.argv
[1:]:
441 parse_list(f
).write(SYS
.stdout
)
442 except (ExpectedError
, IOError, OSError) as e
: